ntermqt 0.1.7__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/scripting/api.py +247 -691
- 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 -107
- 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.7.dist-info → ntermqt-0.1.8.dist-info}/METADATA +88 -28
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/RECORD +14 -9
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.8.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.7.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,63 +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
|
|
|
235
|
+
# Disconnect existing session if any
|
|
222
236
|
if self.state.session:
|
|
223
237
|
self._safe_disconnect()
|
|
224
238
|
|
|
225
239
|
try:
|
|
226
|
-
|
|
227
|
-
sess = self.state.api.connect(
|
|
228
|
-
device,
|
|
229
|
-
credential=cred,
|
|
230
|
-
debug=self.state.debug_mode # <-- This line
|
|
231
|
-
)
|
|
240
|
+
sess = self.state.api.connect(device, credential=cred, debug=debug)
|
|
232
241
|
self.state.session = sess
|
|
233
242
|
self.state.connected_device = sess.device_name
|
|
234
243
|
|
|
@@ -244,9 +253,30 @@ class NTermREPL:
|
|
|
244
253
|
return self._err(f"Connection failed: {e}")
|
|
245
254
|
|
|
246
255
|
if cmd == ":disconnect":
|
|
256
|
+
if not self.state.session:
|
|
257
|
+
return self._ok({"type": "disconnect", "message": "No active session"})
|
|
247
258
|
self._safe_disconnect()
|
|
248
259
|
return self._ok({"type": "disconnect"})
|
|
249
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 =====
|
|
250
280
|
if cmd == ":policy":
|
|
251
281
|
if len(parts) < 2:
|
|
252
282
|
return self._ok({"type": "policy", "mode": self.state.policy.mode})
|
|
@@ -271,10 +301,7 @@ class NTermREPL:
|
|
|
271
301
|
|
|
272
302
|
if cmd == ":format":
|
|
273
303
|
if len(parts) < 2:
|
|
274
|
-
return self._ok({
|
|
275
|
-
"type": "format",
|
|
276
|
-
"format": self.state.output_format,
|
|
277
|
-
})
|
|
304
|
+
return self._ok({"type": "format", "format": self.state.output_format})
|
|
278
305
|
fmt = parts[1].lower()
|
|
279
306
|
if fmt not in ["text", "rich", "json"]:
|
|
280
307
|
return self._err("Format must be 'text', 'rich', or 'json'")
|
|
@@ -302,7 +329,6 @@ class NTermREPL:
|
|
|
302
329
|
else:
|
|
303
330
|
return self._err("Debug mode must be on or off")
|
|
304
331
|
else:
|
|
305
|
-
# Toggle
|
|
306
332
|
self.state.debug_mode = not self.state.debug_mode
|
|
307
333
|
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
308
334
|
|
|
@@ -313,8 +339,180 @@ class NTermREPL:
|
|
|
313
339
|
except Exception as e:
|
|
314
340
|
return self._err(f"Failed to get DB info: {e}")
|
|
315
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
|
+
|
|
316
367
|
return self._err(f"Unknown REPL command: {cmd}")
|
|
317
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
|
+
|
|
318
516
|
# -----------------------
|
|
319
517
|
# CLI send path
|
|
320
518
|
# -----------------------
|
|
@@ -328,15 +526,15 @@ class NTermREPL:
|
|
|
328
526
|
|
|
329
527
|
try:
|
|
330
528
|
started = time.time()
|
|
331
|
-
|
|
529
|
+
|
|
332
530
|
# Determine if we should parse based on output mode
|
|
333
531
|
should_parse = (self.state.output_mode == "parsed")
|
|
334
|
-
|
|
335
|
-
# Apply platform hint if set
|
|
532
|
+
|
|
533
|
+
# Apply platform hint if set
|
|
336
534
|
original_platform = self.state.session.platform
|
|
337
535
|
if self.state.platform_hint:
|
|
338
536
|
self.state.session.platform = self.state.platform_hint
|
|
339
|
-
|
|
537
|
+
|
|
340
538
|
try:
|
|
341
539
|
res: CommandResult = self.state.api.send(
|
|
342
540
|
self.state.session,
|
|
@@ -349,13 +547,13 @@ class NTermREPL:
|
|
|
349
547
|
# Restore original platform
|
|
350
548
|
if self.state.platform_hint:
|
|
351
549
|
self.state.session.platform = original_platform
|
|
352
|
-
|
|
550
|
+
|
|
353
551
|
elapsed = time.time() - started
|
|
354
552
|
|
|
355
553
|
# Clip raw output for safety/transport
|
|
356
554
|
raw = res.raw_output or ""
|
|
357
555
|
if len(raw) > self.state.policy.max_output_chars:
|
|
358
|
-
raw = raw[:
|
|
556
|
+
raw = raw[:self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
359
557
|
|
|
360
558
|
payload = res.to_dict()
|
|
361
559
|
payload["raw_output"] = raw
|
|
@@ -365,13 +563,17 @@ class NTermREPL:
|
|
|
365
563
|
except Exception as e:
|
|
366
564
|
return self._err(f"Command execution failed: {e}")
|
|
367
565
|
|
|
566
|
+
# -----------------------
|
|
567
|
+
# Helpers
|
|
568
|
+
# -----------------------
|
|
569
|
+
|
|
368
570
|
def _safe_disconnect(self) -> None:
|
|
369
571
|
if self.state.session:
|
|
370
572
|
try:
|
|
371
573
|
self.state.api.disconnect(self.state.session)
|
|
372
574
|
finally:
|
|
373
575
|
self.state.session = None
|
|
374
|
-
self.connected_device = None
|
|
576
|
+
self.state.connected_device = None
|
|
375
577
|
|
|
376
578
|
def do_unlock(self, password: str) -> Dict[str, Any]:
|
|
377
579
|
"""Internal method to perform unlock with password."""
|
|
@@ -383,25 +585,60 @@ class NTermREPL:
|
|
|
383
585
|
return self._err(f"Unlock failed: {e}")
|
|
384
586
|
|
|
385
587
|
def _help_text(self) -> str:
|
|
386
|
-
return
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
"""
|
|
405
642
|
|
|
406
643
|
def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
407
644
|
return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
|