ntermqt 0.1.6__py3-none-any.whl → 0.1.8__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/parser/api_help_dialog.py +426 -223
- nterm/parser/tfsm_fire_tester.py +561 -731
- nterm/scripting/api.py +249 -641
- nterm/scripting/models.py +195 -0
- nterm/scripting/platform_data.py +272 -0
- nterm/scripting/platform_utils.py +330 -0
- nterm/scripting/repl.py +344 -103
- nterm/scripting/repl_interactive.py +331 -213
- nterm/scripting/ssh_connection.py +632 -0
- nterm/scripting/test_api_repl.py +290 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/METADATA +88 -28
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/RECORD +15 -10
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.6.dist-info → ntermqt-0.1.8.dist-info}/top_level.txt +0 -0
nterm/scripting/repl.py
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
import os
|
|
14
13
|
import json
|
|
15
14
|
import time
|
|
16
15
|
import shlex
|
|
@@ -19,6 +18,11 @@ from datetime import datetime
|
|
|
19
18
|
from typing import Optional, Dict, Any, List
|
|
20
19
|
|
|
21
20
|
from .api import NTermAPI, ActiveSession, CommandResult
|
|
21
|
+
from .platform_utils import (
|
|
22
|
+
get_platform_command,
|
|
23
|
+
extract_version_info,
|
|
24
|
+
extract_neighbor_info,
|
|
25
|
+
)
|
|
22
26
|
|
|
23
27
|
|
|
24
28
|
@dataclass
|
|
@@ -34,16 +38,12 @@ class REPLPolicy:
|
|
|
34
38
|
def is_allowed(self, command: str) -> bool:
|
|
35
39
|
cmd = command.strip().lower()
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
bad = self.deny_substrings[i].lower()
|
|
40
|
-
if bad in cmd:
|
|
41
|
+
for bad in self.deny_substrings:
|
|
42
|
+
if bad.lower() in cmd:
|
|
41
43
|
return False
|
|
42
|
-
i += 1
|
|
43
44
|
|
|
44
45
|
if self.mode == "read_only":
|
|
45
|
-
#
|
|
46
|
-
# (You can tighten to an allow-list only if you want)
|
|
46
|
+
# Block common config verbs
|
|
47
47
|
write_verbs = [
|
|
48
48
|
"conf t",
|
|
49
49
|
"configure",
|
|
@@ -62,23 +62,16 @@ class REPLPolicy:
|
|
|
62
62
|
"upgrade",
|
|
63
63
|
"install",
|
|
64
64
|
]
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if cmd.startswith(write_verbs[j]):
|
|
65
|
+
for verb in write_verbs:
|
|
66
|
+
if cmd.startswith(verb):
|
|
68
67
|
return False
|
|
69
|
-
j += 1
|
|
70
68
|
|
|
71
69
|
# If allow_prefixes provided, require one of them
|
|
72
70
|
if self.allow_prefixes:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if cmd.startswith(pref):
|
|
78
|
-
ok = True
|
|
79
|
-
break
|
|
80
|
-
k += 1
|
|
81
|
-
return ok
|
|
71
|
+
for pref in self.allow_prefixes:
|
|
72
|
+
if cmd.startswith(pref.lower()):
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
82
75
|
|
|
83
76
|
return True
|
|
84
77
|
|
|
@@ -101,23 +94,36 @@ class NTermREPL:
|
|
|
101
94
|
A minimal command router. This is the "tool surface".
|
|
102
95
|
MCP can call `handle_line()`; humans can type into it.
|
|
103
96
|
|
|
104
|
-
Commands:
|
|
105
|
-
:unlock
|
|
106
|
-
:lock
|
|
107
|
-
:creds [pattern]
|
|
108
|
-
:devices [pattern]
|
|
109
|
-
:
|
|
110
|
-
:
|
|
111
|
-
:
|
|
112
|
-
:
|
|
113
|
-
:
|
|
114
|
-
:
|
|
115
|
-
:
|
|
116
|
-
:
|
|
117
|
-
:
|
|
118
|
-
|
|
119
|
-
:
|
|
120
|
-
:
|
|
97
|
+
Meta Commands:
|
|
98
|
+
:unlock Unlock credential vault (prompts for password)
|
|
99
|
+
:lock Lock credential vault
|
|
100
|
+
:creds [pattern] List credentials
|
|
101
|
+
:devices [pattern] List devices
|
|
102
|
+
:folders List folders
|
|
103
|
+
:connect <device> Connect to device [--cred name] [--debug]
|
|
104
|
+
:disconnect Disconnect current session
|
|
105
|
+
:sessions List all active sessions
|
|
106
|
+
:policy [mode] Get/set policy (read_only|ops)
|
|
107
|
+
:mode [raw|parsed] Get/set output mode
|
|
108
|
+
:format [fmt] Get/set format (text|rich|json)
|
|
109
|
+
:set_hint <platform> Override platform detection
|
|
110
|
+
:clear_hint Use auto-detected platform
|
|
111
|
+
:debug [on|off] Toggle debug mode
|
|
112
|
+
:dbinfo Show TextFSM database info
|
|
113
|
+
:help Show help
|
|
114
|
+
:exit Disconnect and exit
|
|
115
|
+
|
|
116
|
+
Quick Commands (platform-aware):
|
|
117
|
+
:config Fetch running config
|
|
118
|
+
:version Fetch and parse version info
|
|
119
|
+
:interfaces Fetch interface status
|
|
120
|
+
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
121
|
+
:bgp Fetch BGP summary
|
|
122
|
+
:routes Fetch routing table
|
|
123
|
+
:intf <name> Fetch specific interface details
|
|
124
|
+
|
|
125
|
+
Raw Commands:
|
|
126
|
+
(anything else) Runs as CLI on the connected session
|
|
121
127
|
"""
|
|
122
128
|
|
|
123
129
|
def __init__(self, api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
|
|
@@ -127,7 +133,7 @@ class NTermREPL:
|
|
|
127
133
|
policy = REPLPolicy(
|
|
128
134
|
mode="read_only",
|
|
129
135
|
deny_substrings=[
|
|
130
|
-
"terminal monitor", #
|
|
136
|
+
"terminal monitor", # Don't want interactive spam
|
|
131
137
|
],
|
|
132
138
|
allow_prefixes=[],
|
|
133
139
|
)
|
|
@@ -153,16 +159,19 @@ class NTermREPL:
|
|
|
153
159
|
parts = shlex.split(line)
|
|
154
160
|
cmd = parts[0].lower()
|
|
155
161
|
|
|
162
|
+
# ===== Help & Exit =====
|
|
156
163
|
if cmd == ":help":
|
|
157
164
|
return self._ok({"type": "help", "text": self._help_text()})
|
|
158
165
|
|
|
159
166
|
if cmd == ":exit":
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
# Use disconnect_all for clean shutdown
|
|
168
|
+
count = self.state.api.disconnect_all()
|
|
169
|
+
self.state.session = None
|
|
170
|
+
self.state.connected_device = None
|
|
171
|
+
return self._ok({"type": "exit", "disconnected": count})
|
|
163
172
|
|
|
173
|
+
# ===== Vault Commands =====
|
|
164
174
|
if cmd == ":unlock":
|
|
165
|
-
# Password should be provided separately, not in the command line
|
|
166
175
|
if len(parts) > 1:
|
|
167
176
|
return self._err(":unlock takes no arguments. Password will be prompted securely.")
|
|
168
177
|
return self._ok({"type": "unlock_prompt", "message": "Please provide vault password"})
|
|
@@ -172,59 +181,63 @@ class NTermREPL:
|
|
|
172
181
|
self.state.vault_unlocked = False
|
|
173
182
|
return self._ok({"type": "lock", "vault_unlocked": False})
|
|
174
183
|
|
|
184
|
+
# ===== Inventory Commands =====
|
|
175
185
|
if cmd == ":creds":
|
|
176
186
|
if not self.state.api.vault_unlocked:
|
|
177
|
-
return self._err("Vault is locked. Run :unlock
|
|
178
|
-
|
|
179
|
-
pattern = None
|
|
180
|
-
if len(parts) >= 2:
|
|
181
|
-
pattern = parts[1]
|
|
187
|
+
return self._err("Vault is locked. Run :unlock first.")
|
|
182
188
|
|
|
189
|
+
pattern = parts[1] if len(parts) >= 2 else None
|
|
183
190
|
creds = self.state.api.credentials(pattern=pattern)
|
|
184
|
-
rows
|
|
185
|
-
i = 0
|
|
186
|
-
while i < len(creds):
|
|
187
|
-
rows.append(creds[i].to_dict())
|
|
188
|
-
i += 1
|
|
191
|
+
rows = [c.to_dict() for c in creds]
|
|
189
192
|
return self._ok({"type": "credentials", "credentials": rows})
|
|
190
193
|
|
|
191
194
|
if cmd == ":devices":
|
|
192
|
-
pattern = None
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
pattern = parts[1] if len(parts) >= 2 else None
|
|
196
|
+
folder = None
|
|
197
|
+
|
|
198
|
+
# Check for --folder flag
|
|
199
|
+
for i, p in enumerate(parts):
|
|
200
|
+
if p == "--folder" and i + 1 < len(parts):
|
|
201
|
+
folder = parts[i + 1]
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
devs = self.state.api.devices(pattern=pattern, folder=folder)
|
|
205
|
+
rows = [d.to_dict() for d in devs]
|
|
202
206
|
return self._ok({"type": "devices", "devices": rows})
|
|
203
207
|
|
|
208
|
+
if cmd == ":folders":
|
|
209
|
+
folders = self.state.api.folders()
|
|
210
|
+
return self._ok({"type": "folders", "folders": folders})
|
|
211
|
+
|
|
212
|
+
# ===== Session Commands =====
|
|
204
213
|
if cmd == ":connect":
|
|
205
214
|
if len(parts) < 2:
|
|
206
|
-
return self._err("Usage: :connect <device> [--cred name]")
|
|
215
|
+
return self._err("Usage: :connect <device> [--cred name] [--debug]")
|
|
207
216
|
|
|
208
217
|
device = parts[1]
|
|
209
218
|
cred = None
|
|
219
|
+
debug = self.state.debug_mode
|
|
210
220
|
|
|
211
221
|
i = 2
|
|
212
222
|
while i < len(parts):
|
|
213
|
-
if parts[i] == "--cred":
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
if parts[i] == "--cred" and i + 1 < len(parts):
|
|
224
|
+
cred = parts[i + 1]
|
|
225
|
+
i += 2
|
|
226
|
+
elif parts[i] == "--debug":
|
|
227
|
+
debug = True
|
|
228
|
+
i += 1
|
|
229
|
+
else:
|
|
230
|
+
i += 1
|
|
218
231
|
|
|
219
232
|
if not self.state.api.vault_unlocked:
|
|
220
|
-
return self._err("Vault is locked. Run :unlock
|
|
233
|
+
return self._err("Vault is locked. Run :unlock first.")
|
|
221
234
|
|
|
222
|
-
#
|
|
235
|
+
# Disconnect existing session if any
|
|
223
236
|
if self.state.session:
|
|
224
237
|
self._safe_disconnect()
|
|
225
238
|
|
|
226
239
|
try:
|
|
227
|
-
sess = self.state.api.connect(device, credential=cred)
|
|
240
|
+
sess = self.state.api.connect(device, credential=cred, debug=debug)
|
|
228
241
|
self.state.session = sess
|
|
229
242
|
self.state.connected_device = sess.device_name
|
|
230
243
|
|
|
@@ -240,9 +253,30 @@ class NTermREPL:
|
|
|
240
253
|
return self._err(f"Connection failed: {e}")
|
|
241
254
|
|
|
242
255
|
if cmd == ":disconnect":
|
|
256
|
+
if not self.state.session:
|
|
257
|
+
return self._ok({"type": "disconnect", "message": "No active session"})
|
|
243
258
|
self._safe_disconnect()
|
|
244
259
|
return self._ok({"type": "disconnect"})
|
|
245
260
|
|
|
261
|
+
if cmd == ":sessions":
|
|
262
|
+
sessions = self.state.api.active_sessions()
|
|
263
|
+
rows = []
|
|
264
|
+
for s in sessions:
|
|
265
|
+
rows.append({
|
|
266
|
+
"device": s.device_name,
|
|
267
|
+
"hostname": s.hostname,
|
|
268
|
+
"port": s.port,
|
|
269
|
+
"platform": s.platform,
|
|
270
|
+
"prompt": s.prompt,
|
|
271
|
+
"connected": s.is_connected(),
|
|
272
|
+
})
|
|
273
|
+
return self._ok({
|
|
274
|
+
"type": "sessions",
|
|
275
|
+
"sessions": rows,
|
|
276
|
+
"current": self.state.connected_device,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
# ===== Settings Commands =====
|
|
246
280
|
if cmd == ":policy":
|
|
247
281
|
if len(parts) < 2:
|
|
248
282
|
return self._ok({"type": "policy", "mode": self.state.policy.mode})
|
|
@@ -267,10 +301,7 @@ class NTermREPL:
|
|
|
267
301
|
|
|
268
302
|
if cmd == ":format":
|
|
269
303
|
if len(parts) < 2:
|
|
270
|
-
return self._ok({
|
|
271
|
-
"type": "format",
|
|
272
|
-
"format": self.state.output_format,
|
|
273
|
-
})
|
|
304
|
+
return self._ok({"type": "format", "format": self.state.output_format})
|
|
274
305
|
fmt = parts[1].lower()
|
|
275
306
|
if fmt not in ["text", "rich", "json"]:
|
|
276
307
|
return self._err("Format must be 'text', 'rich', or 'json'")
|
|
@@ -298,7 +329,6 @@ class NTermREPL:
|
|
|
298
329
|
else:
|
|
299
330
|
return self._err("Debug mode must be on or off")
|
|
300
331
|
else:
|
|
301
|
-
# Toggle
|
|
302
332
|
self.state.debug_mode = not self.state.debug_mode
|
|
303
333
|
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
304
334
|
|
|
@@ -309,8 +339,180 @@ class NTermREPL:
|
|
|
309
339
|
except Exception as e:
|
|
310
340
|
return self._err(f"Failed to get DB info: {e}")
|
|
311
341
|
|
|
342
|
+
# ===== Quick Commands (Platform-Aware) =====
|
|
343
|
+
if cmd == ":config":
|
|
344
|
+
return self._quick_command('config', parse=False, timeout=120)
|
|
345
|
+
|
|
346
|
+
if cmd == ":version":
|
|
347
|
+
return self._quick_version()
|
|
348
|
+
|
|
349
|
+
if cmd == ":interfaces":
|
|
350
|
+
return self._quick_command('interfaces_status', parse=True)
|
|
351
|
+
|
|
352
|
+
if cmd == ":neighbors":
|
|
353
|
+
return self._quick_neighbors()
|
|
354
|
+
|
|
355
|
+
if cmd == ":bgp":
|
|
356
|
+
return self._quick_command('bgp_summary', parse=True)
|
|
357
|
+
|
|
358
|
+
if cmd == ":routes":
|
|
359
|
+
return self._quick_command('routing_table', parse=True, timeout=60)
|
|
360
|
+
|
|
361
|
+
if cmd == ":intf":
|
|
362
|
+
if len(parts) < 2:
|
|
363
|
+
return self._err("Usage: :intf <interface_name> (e.g., :intf Gi0/1)")
|
|
364
|
+
intf_name = parts[1]
|
|
365
|
+
return self._quick_command('interface_detail', parse=True, name=intf_name)
|
|
366
|
+
|
|
312
367
|
return self._err(f"Unknown REPL command: {cmd}")
|
|
313
368
|
|
|
369
|
+
# -----------------------
|
|
370
|
+
# Quick Command Helpers
|
|
371
|
+
# -----------------------
|
|
372
|
+
|
|
373
|
+
def _quick_command(
|
|
374
|
+
self,
|
|
375
|
+
command_type: str,
|
|
376
|
+
parse: bool = True,
|
|
377
|
+
timeout: int = 30,
|
|
378
|
+
**kwargs
|
|
379
|
+
) -> Dict[str, Any]:
|
|
380
|
+
"""Execute a platform-aware command."""
|
|
381
|
+
if not self.state.session:
|
|
382
|
+
return self._err("Not connected. Use :connect <device>")
|
|
383
|
+
|
|
384
|
+
platform = self.state.platform_hint or self.state.session.platform
|
|
385
|
+
cmd = get_platform_command(platform, command_type, **kwargs)
|
|
386
|
+
|
|
387
|
+
if not cmd:
|
|
388
|
+
return self._err(f"Command '{command_type}' not available for platform '{platform}'")
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
started = time.time()
|
|
392
|
+
result = self.state.api.send(
|
|
393
|
+
self.state.session,
|
|
394
|
+
cmd,
|
|
395
|
+
timeout=timeout,
|
|
396
|
+
parse=parse,
|
|
397
|
+
normalize=True,
|
|
398
|
+
)
|
|
399
|
+
elapsed = time.time() - started
|
|
400
|
+
|
|
401
|
+
payload = result.to_dict()
|
|
402
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
403
|
+
payload["command_type"] = command_type
|
|
404
|
+
|
|
405
|
+
# Truncate if needed
|
|
406
|
+
raw = payload.get("raw_output", "")
|
|
407
|
+
if len(raw) > self.state.policy.max_output_chars:
|
|
408
|
+
payload["raw_output"] = raw[:self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
409
|
+
|
|
410
|
+
return self._ok({"type": "result", "result": payload})
|
|
411
|
+
|
|
412
|
+
except Exception as e:
|
|
413
|
+
return self._err(f"Command failed: {e}")
|
|
414
|
+
|
|
415
|
+
def _quick_version(self) -> Dict[str, Any]:
|
|
416
|
+
"""Fetch and extract version info."""
|
|
417
|
+
if not self.state.session:
|
|
418
|
+
return self._err("Not connected. Use :connect <device>")
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
started = time.time()
|
|
422
|
+
result = self.state.api.send_platform_command(
|
|
423
|
+
self.state.session,
|
|
424
|
+
'version',
|
|
425
|
+
parse=True,
|
|
426
|
+
timeout=30,
|
|
427
|
+
)
|
|
428
|
+
elapsed = time.time() - started
|
|
429
|
+
|
|
430
|
+
if not result:
|
|
431
|
+
return self._err("Version command not available for this platform")
|
|
432
|
+
|
|
433
|
+
# Extract structured version info
|
|
434
|
+
version_info = extract_version_info(
|
|
435
|
+
result.parsed_data,
|
|
436
|
+
self.state.platform_hint or self.state.session.platform
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
payload = result.to_dict()
|
|
440
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
441
|
+
payload["command_type"] = "version"
|
|
442
|
+
payload["version_info"] = version_info
|
|
443
|
+
|
|
444
|
+
return self._ok({"type": "version", "result": payload})
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
return self._err(f"Version command failed: {e}")
|
|
448
|
+
|
|
449
|
+
def _quick_neighbors(self) -> Dict[str, Any]:
|
|
450
|
+
"""Fetch CDP/LLDP neighbors with fallback."""
|
|
451
|
+
if not self.state.session:
|
|
452
|
+
return self._err("Not connected. Use :connect <device>")
|
|
453
|
+
|
|
454
|
+
platform = self.state.platform_hint or self.state.session.platform
|
|
455
|
+
|
|
456
|
+
# Build command list - CDP first for Cisco, LLDP first for others
|
|
457
|
+
cdp_cmd = get_platform_command(platform, 'neighbors_cdp')
|
|
458
|
+
lldp_cmd = get_platform_command(platform, 'neighbors_lldp')
|
|
459
|
+
|
|
460
|
+
if platform and 'cisco' in platform:
|
|
461
|
+
commands = [cdp_cmd, lldp_cmd]
|
|
462
|
+
else:
|
|
463
|
+
commands = [lldp_cmd, cdp_cmd]
|
|
464
|
+
|
|
465
|
+
# Filter None commands
|
|
466
|
+
commands = [c for c in commands if c]
|
|
467
|
+
|
|
468
|
+
if not commands:
|
|
469
|
+
return self._err(f"No neighbor discovery commands available for platform '{platform}'")
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
started = time.time()
|
|
473
|
+
result = self.state.api.send_first(
|
|
474
|
+
self.state.session,
|
|
475
|
+
commands,
|
|
476
|
+
parse=True,
|
|
477
|
+
timeout=30,
|
|
478
|
+
require_parsed=True,
|
|
479
|
+
)
|
|
480
|
+
elapsed = time.time() - started
|
|
481
|
+
|
|
482
|
+
if not result:
|
|
483
|
+
# Try without requiring parsed data
|
|
484
|
+
result = self.state.api.send_first(
|
|
485
|
+
self.state.session,
|
|
486
|
+
commands,
|
|
487
|
+
parse=True,
|
|
488
|
+
timeout=30,
|
|
489
|
+
require_parsed=False,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if not result:
|
|
493
|
+
return self._ok({
|
|
494
|
+
"type": "neighbors",
|
|
495
|
+
"result": {
|
|
496
|
+
"command": "CDP/LLDP",
|
|
497
|
+
"raw_output": "No neighbors found or commands failed",
|
|
498
|
+
"parsed_data": [],
|
|
499
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
# Extract normalized neighbor info
|
|
504
|
+
neighbors = extract_neighbor_info(result.parsed_data) if result.parsed_data else []
|
|
505
|
+
|
|
506
|
+
payload = result.to_dict()
|
|
507
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
508
|
+
payload["command_type"] = "neighbors"
|
|
509
|
+
payload["neighbor_info"] = neighbors
|
|
510
|
+
|
|
511
|
+
return self._ok({"type": "neighbors", "result": payload})
|
|
512
|
+
|
|
513
|
+
except Exception as e:
|
|
514
|
+
return self._err(f"Neighbor discovery failed: {e}")
|
|
515
|
+
|
|
314
516
|
# -----------------------
|
|
315
517
|
# CLI send path
|
|
316
518
|
# -----------------------
|
|
@@ -324,15 +526,15 @@ class NTermREPL:
|
|
|
324
526
|
|
|
325
527
|
try:
|
|
326
528
|
started = time.time()
|
|
327
|
-
|
|
529
|
+
|
|
328
530
|
# Determine if we should parse based on output mode
|
|
329
531
|
should_parse = (self.state.output_mode == "parsed")
|
|
330
|
-
|
|
331
|
-
# Apply platform hint if set
|
|
532
|
+
|
|
533
|
+
# Apply platform hint if set
|
|
332
534
|
original_platform = self.state.session.platform
|
|
333
535
|
if self.state.platform_hint:
|
|
334
536
|
self.state.session.platform = self.state.platform_hint
|
|
335
|
-
|
|
537
|
+
|
|
336
538
|
try:
|
|
337
539
|
res: CommandResult = self.state.api.send(
|
|
338
540
|
self.state.session,
|
|
@@ -345,13 +547,13 @@ class NTermREPL:
|
|
|
345
547
|
# Restore original platform
|
|
346
548
|
if self.state.platform_hint:
|
|
347
549
|
self.state.session.platform = original_platform
|
|
348
|
-
|
|
550
|
+
|
|
349
551
|
elapsed = time.time() - started
|
|
350
552
|
|
|
351
553
|
# Clip raw output for safety/transport
|
|
352
554
|
raw = res.raw_output or ""
|
|
353
555
|
if len(raw) > self.state.policy.max_output_chars:
|
|
354
|
-
raw = raw[:
|
|
556
|
+
raw = raw[:self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
355
557
|
|
|
356
558
|
payload = res.to_dict()
|
|
357
559
|
payload["raw_output"] = raw
|
|
@@ -361,13 +563,17 @@ class NTermREPL:
|
|
|
361
563
|
except Exception as e:
|
|
362
564
|
return self._err(f"Command execution failed: {e}")
|
|
363
565
|
|
|
566
|
+
# -----------------------
|
|
567
|
+
# Helpers
|
|
568
|
+
# -----------------------
|
|
569
|
+
|
|
364
570
|
def _safe_disconnect(self) -> None:
|
|
365
571
|
if self.state.session:
|
|
366
572
|
try:
|
|
367
573
|
self.state.api.disconnect(self.state.session)
|
|
368
574
|
finally:
|
|
369
575
|
self.state.session = None
|
|
370
|
-
self.connected_device = None
|
|
576
|
+
self.state.connected_device = None
|
|
371
577
|
|
|
372
578
|
def do_unlock(self, password: str) -> Dict[str, Any]:
|
|
373
579
|
"""Internal method to perform unlock with password."""
|
|
@@ -379,25 +585,60 @@ class NTermREPL:
|
|
|
379
585
|
return self._err(f"Unlock failed: {e}")
|
|
380
586
|
|
|
381
587
|
def _help_text(self) -> str:
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
588
|
+
return """
|
|
589
|
+
nterm REPL Commands
|
|
590
|
+
===================
|
|
591
|
+
|
|
592
|
+
Vault:
|
|
593
|
+
:unlock Unlock credential vault (prompts securely)
|
|
594
|
+
:lock Lock credential vault
|
|
595
|
+
|
|
596
|
+
Inventory:
|
|
597
|
+
:creds [pattern] List credentials (supports glob patterns)
|
|
598
|
+
:devices [pattern] List devices [--folder name]
|
|
599
|
+
:folders List all folders
|
|
600
|
+
|
|
601
|
+
Sessions:
|
|
602
|
+
:connect <device> Connect to device [--cred name] [--debug]
|
|
603
|
+
:disconnect Disconnect current session
|
|
604
|
+
:sessions List all active sessions
|
|
605
|
+
|
|
606
|
+
Quick Commands (platform-aware, auto-selects correct syntax):
|
|
607
|
+
:config Fetch running configuration
|
|
608
|
+
:version Fetch and parse version info
|
|
609
|
+
:interfaces Fetch interface status
|
|
610
|
+
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
611
|
+
:bgp Fetch BGP summary
|
|
612
|
+
:routes Fetch routing table
|
|
613
|
+
:intf <name> Fetch specific interface details
|
|
614
|
+
|
|
615
|
+
Settings:
|
|
616
|
+
:policy [mode] Get/set policy mode (read_only|ops)
|
|
617
|
+
:mode [raw|parsed] Get/set output mode
|
|
618
|
+
:format [fmt] Get/set display format (text|rich|json)
|
|
619
|
+
:set_hint <platform> Override platform detection (cisco_ios, arista_eos, etc.)
|
|
620
|
+
:clear_hint Use auto-detected platform
|
|
621
|
+
:debug [on|off] Toggle debug mode
|
|
622
|
+
|
|
623
|
+
Info:
|
|
624
|
+
:dbinfo Show TextFSM database status
|
|
625
|
+
:help Show this help
|
|
626
|
+
:exit Disconnect all and exit
|
|
627
|
+
|
|
628
|
+
Raw Commands:
|
|
629
|
+
(anything else) Sends as CLI command to connected device
|
|
630
|
+
|
|
631
|
+
Examples:
|
|
632
|
+
:unlock
|
|
633
|
+
:devices *leaf*
|
|
634
|
+
:connect usa-leaf-1
|
|
635
|
+
:version
|
|
636
|
+
:interfaces
|
|
637
|
+
:neighbors
|
|
638
|
+
show ip route
|
|
639
|
+
:disconnect
|
|
640
|
+
:exit
|
|
641
|
+
"""
|
|
401
642
|
|
|
402
643
|
def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
403
644
|
return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
|