ntermqt 0.1.7__py3-none-any.whl → 0.1.9__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 +421 -701
- nterm/scripting/models.py +195 -0
- nterm/scripting/platform_data.py +272 -0
- nterm/scripting/platform_utils.py +596 -0
- nterm/scripting/repl.py +527 -131
- nterm/scripting/repl_interactive.py +356 -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.9.dist-info}/METADATA +89 -29
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/RECORD +14 -9
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.7.dist-info → ntermqt-0.1.9.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,38 @@ 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
|
+
:disconnect_all Disconnect all sessions
|
|
106
|
+
:switch <device> Switch to another active session
|
|
107
|
+
:sessions List all active sessions
|
|
108
|
+
:policy [mode] Get/set policy (read_only|ops)
|
|
109
|
+
:mode [raw|parsed] Get/set output mode
|
|
110
|
+
:format [fmt] Get/set format (text|rich|json)
|
|
111
|
+
:set_hint <platform> Override platform detection
|
|
112
|
+
:clear_hint Use auto-detected platform
|
|
113
|
+
:debug [on|off] Toggle debug mode
|
|
114
|
+
:dbinfo Show TextFSM database info
|
|
115
|
+
:help Show help
|
|
116
|
+
:exit Disconnect and exit
|
|
117
|
+
|
|
118
|
+
Quick Commands (platform-aware):
|
|
119
|
+
:config Fetch running config
|
|
120
|
+
:version Fetch and parse version info
|
|
121
|
+
:interfaces Fetch interface status
|
|
122
|
+
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
123
|
+
:bgp Fetch BGP summary
|
|
124
|
+
:routes Fetch routing table
|
|
125
|
+
:intf <n> Fetch specific interface details
|
|
126
|
+
|
|
127
|
+
Raw Commands:
|
|
128
|
+
(anything else) Runs as CLI on the connected session
|
|
121
129
|
"""
|
|
122
130
|
|
|
123
131
|
def __init__(self, api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
|
|
@@ -127,7 +135,7 @@ class NTermREPL:
|
|
|
127
135
|
policy = REPLPolicy(
|
|
128
136
|
mode="read_only",
|
|
129
137
|
deny_substrings=[
|
|
130
|
-
"terminal monitor", #
|
|
138
|
+
"terminal monitor", # Don't want interactive spam
|
|
131
139
|
],
|
|
132
140
|
allow_prefixes=[],
|
|
133
141
|
)
|
|
@@ -153,16 +161,19 @@ class NTermREPL:
|
|
|
153
161
|
parts = shlex.split(line)
|
|
154
162
|
cmd = parts[0].lower()
|
|
155
163
|
|
|
164
|
+
# ===== Help & Exit =====
|
|
156
165
|
if cmd == ":help":
|
|
157
166
|
return self._ok({"type": "help", "text": self._help_text()})
|
|
158
167
|
|
|
159
168
|
if cmd == ":exit":
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
# Use disconnect_all for clean shutdown
|
|
170
|
+
count = self.state.api.disconnect_all()
|
|
171
|
+
self.state.session = None
|
|
172
|
+
self.state.connected_device = None
|
|
173
|
+
return self._ok({"type": "exit", "disconnected": count})
|
|
163
174
|
|
|
175
|
+
# ===== Vault Commands =====
|
|
164
176
|
if cmd == ":unlock":
|
|
165
|
-
# Password should be provided separately, not in the command line
|
|
166
177
|
if len(parts) > 1:
|
|
167
178
|
return self._err(":unlock takes no arguments. Password will be prompted securely.")
|
|
168
179
|
return self._ok({"type": "unlock_prompt", "message": "Please provide vault password"})
|
|
@@ -172,63 +183,80 @@ class NTermREPL:
|
|
|
172
183
|
self.state.vault_unlocked = False
|
|
173
184
|
return self._ok({"type": "lock", "vault_unlocked": False})
|
|
174
185
|
|
|
186
|
+
# ===== Inventory Commands =====
|
|
175
187
|
if cmd == ":creds":
|
|
176
188
|
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]
|
|
189
|
+
return self._err("Vault is locked. Run :unlock first.")
|
|
182
190
|
|
|
191
|
+
pattern = parts[1] if len(parts) >= 2 else None
|
|
183
192
|
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
|
|
193
|
+
rows = [c.to_dict() for c in creds]
|
|
189
194
|
return self._ok({"type": "credentials", "credentials": rows})
|
|
190
195
|
|
|
191
196
|
if cmd == ":devices":
|
|
192
|
-
pattern = None
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
pattern = parts[1] if len(parts) >= 2 else None
|
|
198
|
+
folder = None
|
|
199
|
+
|
|
200
|
+
# Check for --folder flag
|
|
201
|
+
for i, p in enumerate(parts):
|
|
202
|
+
if p == "--folder" and i + 1 < len(parts):
|
|
203
|
+
folder = parts[i + 1]
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
devs = self.state.api.devices(pattern=pattern, folder=folder)
|
|
207
|
+
rows = [d.to_dict() for d in devs]
|
|
202
208
|
return self._ok({"type": "devices", "devices": rows})
|
|
203
209
|
|
|
210
|
+
if cmd == ":folders":
|
|
211
|
+
folders = self.state.api.folders()
|
|
212
|
+
return self._ok({"type": "folders", "folders": folders})
|
|
213
|
+
|
|
214
|
+
# ===== Session Commands =====
|
|
204
215
|
if cmd == ":connect":
|
|
205
216
|
if len(parts) < 2:
|
|
206
|
-
return self._err("Usage: :connect <device> [--cred name]")
|
|
217
|
+
return self._err("Usage: :connect <device> [--cred name] [--debug]")
|
|
207
218
|
|
|
208
219
|
device = parts[1]
|
|
209
220
|
cred = None
|
|
221
|
+
debug = self.state.debug_mode
|
|
210
222
|
|
|
211
223
|
i = 2
|
|
212
224
|
while i < len(parts):
|
|
213
|
-
if parts[i] == "--cred":
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
if parts[i] == "--cred" and i + 1 < len(parts):
|
|
226
|
+
cred = parts[i + 1]
|
|
227
|
+
i += 2
|
|
228
|
+
elif parts[i] == "--debug":
|
|
229
|
+
debug = True
|
|
230
|
+
i += 1
|
|
231
|
+
else:
|
|
232
|
+
i += 1
|
|
218
233
|
|
|
219
234
|
if not self.state.api.vault_unlocked:
|
|
220
|
-
return self._err("Vault is locked. Run :unlock
|
|
221
|
-
|
|
222
|
-
if
|
|
223
|
-
|
|
235
|
+
return self._err("Vault is locked. Run :unlock first.")
|
|
236
|
+
|
|
237
|
+
# Check if already connected to this device
|
|
238
|
+
existing_sessions = self.state.api.active_sessions()
|
|
239
|
+
for sess in existing_sessions:
|
|
240
|
+
if sess.device_name == device:
|
|
241
|
+
# Already connected - just switch to it
|
|
242
|
+
self.state.session = sess
|
|
243
|
+
self.state.connected_device = sess.device_name
|
|
244
|
+
return self._ok({
|
|
245
|
+
"type": "switch",
|
|
246
|
+
"device": sess.device_name,
|
|
247
|
+
"hostname": sess.hostname,
|
|
248
|
+
"port": sess.port,
|
|
249
|
+
"platform": sess.platform,
|
|
250
|
+
"prompt": sess.prompt,
|
|
251
|
+
"message": "Already connected - switched to existing session",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
# NOTE: We no longer disconnect the existing session!
|
|
255
|
+
# Old sessions stay active in the background.
|
|
256
|
+
# User can switch back with :switch or disconnect with :disconnect
|
|
224
257
|
|
|
225
258
|
try:
|
|
226
|
-
|
|
227
|
-
sess = self.state.api.connect(
|
|
228
|
-
device,
|
|
229
|
-
credential=cred,
|
|
230
|
-
debug=self.state.debug_mode # <-- This line
|
|
231
|
-
)
|
|
259
|
+
sess = self.state.api.connect(device, credential=cred, debug=debug)
|
|
232
260
|
self.state.session = sess
|
|
233
261
|
self.state.connected_device = sess.device_name
|
|
234
262
|
|
|
@@ -244,9 +272,82 @@ class NTermREPL:
|
|
|
244
272
|
return self._err(f"Connection failed: {e}")
|
|
245
273
|
|
|
246
274
|
if cmd == ":disconnect":
|
|
275
|
+
if not self.state.session:
|
|
276
|
+
return self._ok({"type": "disconnect", "message": "No active session"})
|
|
277
|
+
|
|
278
|
+
device_name = self.state.connected_device
|
|
247
279
|
self._safe_disconnect()
|
|
248
|
-
return self._ok({"type": "disconnect"})
|
|
249
280
|
|
|
281
|
+
# Try to switch to another active session if available
|
|
282
|
+
remaining = self.state.api.active_sessions()
|
|
283
|
+
if remaining:
|
|
284
|
+
self.state.session = remaining[0]
|
|
285
|
+
self.state.connected_device = remaining[0].device_name
|
|
286
|
+
return self._ok({
|
|
287
|
+
"type": "disconnect",
|
|
288
|
+
"disconnected": device_name,
|
|
289
|
+
"switched_to": self.state.connected_device,
|
|
290
|
+
"message": f"Disconnected {device_name}, switched to {self.state.connected_device}",
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
return self._ok({"type": "disconnect", "disconnected": device_name})
|
|
294
|
+
|
|
295
|
+
if cmd == ":disconnect_all":
|
|
296
|
+
count = self.state.api.disconnect_all()
|
|
297
|
+
self.state.session = None
|
|
298
|
+
self.state.connected_device = None
|
|
299
|
+
return self._ok({"type": "disconnect_all", "count": count})
|
|
300
|
+
|
|
301
|
+
if cmd == ":switch":
|
|
302
|
+
if len(parts) < 2:
|
|
303
|
+
# Show available sessions
|
|
304
|
+
sessions = self.state.api.active_sessions()
|
|
305
|
+
if not sessions:
|
|
306
|
+
return self._err("No active sessions. Use :connect <device> first.")
|
|
307
|
+
|
|
308
|
+
session_names = [s.device_name for s in sessions]
|
|
309
|
+
return self._err(f"Usage: :switch <device>\nActive sessions: {', '.join(session_names)}")
|
|
310
|
+
|
|
311
|
+
target_device = parts[1]
|
|
312
|
+
|
|
313
|
+
# Find the session
|
|
314
|
+
sessions = self.state.api.active_sessions()
|
|
315
|
+
for sess in sessions:
|
|
316
|
+
if sess.device_name == target_device:
|
|
317
|
+
self.state.session = sess
|
|
318
|
+
self.state.connected_device = sess.device_name
|
|
319
|
+
return self._ok({
|
|
320
|
+
"type": "switch",
|
|
321
|
+
"device": sess.device_name,
|
|
322
|
+
"hostname": sess.hostname,
|
|
323
|
+
"port": sess.port,
|
|
324
|
+
"platform": sess.platform,
|
|
325
|
+
"prompt": sess.prompt,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
# Not found
|
|
329
|
+
session_names = [s.device_name for s in sessions]
|
|
330
|
+
return self._err(f"Session '{target_device}' not found.\nActive sessions: {', '.join(session_names)}")
|
|
331
|
+
|
|
332
|
+
if cmd == ":sessions":
|
|
333
|
+
sessions = self.state.api.active_sessions()
|
|
334
|
+
rows = []
|
|
335
|
+
for s in sessions:
|
|
336
|
+
rows.append({
|
|
337
|
+
"device": s.device_name,
|
|
338
|
+
"hostname": s.hostname,
|
|
339
|
+
"port": s.port,
|
|
340
|
+
"platform": s.platform,
|
|
341
|
+
"prompt": s.prompt,
|
|
342
|
+
"connected": s.is_connected(),
|
|
343
|
+
})
|
|
344
|
+
return self._ok({
|
|
345
|
+
"type": "sessions",
|
|
346
|
+
"sessions": rows,
|
|
347
|
+
"current": self.state.connected_device,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
# ===== Settings Commands =====
|
|
250
351
|
if cmd == ":policy":
|
|
251
352
|
if len(parts) < 2:
|
|
252
353
|
return self._ok({"type": "policy", "mode": self.state.policy.mode})
|
|
@@ -265,56 +366,308 @@ class NTermREPL:
|
|
|
265
366
|
})
|
|
266
367
|
mode = parts[1].lower()
|
|
267
368
|
if mode not in ["raw", "parsed"]:
|
|
268
|
-
return self._err("Mode must be
|
|
369
|
+
return self._err("Mode must be raw or parsed")
|
|
269
370
|
self.state.output_mode = mode
|
|
270
371
|
return self._ok({"type": "mode", "mode": mode})
|
|
271
372
|
|
|
272
373
|
if cmd == ":format":
|
|
273
374
|
if len(parts) < 2:
|
|
274
|
-
return self._ok({
|
|
275
|
-
"type": "format",
|
|
276
|
-
"format": self.state.output_format,
|
|
277
|
-
})
|
|
375
|
+
return self._ok({"type": "format", "format": self.state.output_format})
|
|
278
376
|
fmt = parts[1].lower()
|
|
279
377
|
if fmt not in ["text", "rich", "json"]:
|
|
280
|
-
return self._err("Format must be
|
|
378
|
+
return self._err("Format must be text, rich, or json")
|
|
281
379
|
self.state.output_format = fmt
|
|
282
380
|
return self._ok({"type": "format", "format": fmt})
|
|
283
381
|
|
|
284
382
|
if cmd == ":set_hint":
|
|
285
383
|
if len(parts) < 2:
|
|
286
384
|
return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
|
|
287
|
-
|
|
288
|
-
self.state.platform_hint
|
|
289
|
-
return self._ok({"type": "set_hint", "platform_hint": platform})
|
|
385
|
+
self.state.platform_hint = parts[1]
|
|
386
|
+
return self._ok({"type": "set_hint", "platform_hint": self.state.platform_hint})
|
|
290
387
|
|
|
291
388
|
if cmd == ":clear_hint":
|
|
292
389
|
self.state.platform_hint = None
|
|
293
390
|
return self._ok({"type": "clear_hint"})
|
|
294
391
|
|
|
295
392
|
if cmd == ":debug":
|
|
296
|
-
if len(parts)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
elif mode in ["off", "false", "0"]:
|
|
301
|
-
self.state.debug_mode = False
|
|
302
|
-
else:
|
|
303
|
-
return self._err("Debug mode must be on or off")
|
|
304
|
-
else:
|
|
305
|
-
# Toggle
|
|
306
|
-
self.state.debug_mode = not self.state.debug_mode
|
|
393
|
+
if len(parts) < 2:
|
|
394
|
+
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
395
|
+
val = parts[1].lower()
|
|
396
|
+
self.state.debug_mode = val in ["on", "true", "1", "yes"]
|
|
307
397
|
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
308
398
|
|
|
309
399
|
if cmd == ":dbinfo":
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
400
|
+
info = self.state.api.db_info()
|
|
401
|
+
return self._ok({"type": "dbinfo", "db_info": info})
|
|
402
|
+
|
|
403
|
+
# ===== Quick Commands =====
|
|
404
|
+
if cmd == ":config":
|
|
405
|
+
return self._quick_config()
|
|
406
|
+
|
|
407
|
+
if cmd == ":version":
|
|
408
|
+
return self._quick_version()
|
|
409
|
+
|
|
410
|
+
if cmd == ":interfaces":
|
|
411
|
+
return self._quick_interfaces()
|
|
412
|
+
|
|
413
|
+
if cmd == ":neighbors":
|
|
414
|
+
return self._quick_neighbors()
|
|
415
|
+
|
|
416
|
+
if cmd == ":bgp":
|
|
417
|
+
return self._quick_bgp()
|
|
418
|
+
|
|
419
|
+
if cmd == ":routes":
|
|
420
|
+
return self._quick_routes()
|
|
421
|
+
|
|
422
|
+
if cmd == ":intf":
|
|
423
|
+
if len(parts) < 2:
|
|
424
|
+
return self._err("Usage: :intf <interface> (e.g., :intf Gi0/1)")
|
|
425
|
+
return self._quick_interface_detail(parts[1])
|
|
315
426
|
|
|
316
427
|
return self._err(f"Unknown REPL command: {cmd}")
|
|
317
428
|
|
|
429
|
+
# -----------------------
|
|
430
|
+
# Quick commands
|
|
431
|
+
# -----------------------
|
|
432
|
+
|
|
433
|
+
def _quick_config(self) -> Dict[str, Any]:
|
|
434
|
+
"""Fetch running configuration."""
|
|
435
|
+
if not self.state.session:
|
|
436
|
+
return self._err("Not connected. Use :connect <device>")
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
started = time.time()
|
|
440
|
+
result = self.state.api.send_platform_command(
|
|
441
|
+
self.state.session,
|
|
442
|
+
'config',
|
|
443
|
+
parse=False, # Config is typically not parsed
|
|
444
|
+
timeout=60,
|
|
445
|
+
)
|
|
446
|
+
elapsed = time.time() - started
|
|
447
|
+
|
|
448
|
+
if not result:
|
|
449
|
+
return self._err("Config command not available for this platform")
|
|
450
|
+
|
|
451
|
+
payload = result.to_dict()
|
|
452
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
453
|
+
payload["command_type"] = "config"
|
|
454
|
+
|
|
455
|
+
return self._ok({"type": "config", "result": payload})
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
return self._err(f"Config fetch failed: {e}")
|
|
459
|
+
|
|
460
|
+
def _quick_version(self) -> Dict[str, Any]:
|
|
461
|
+
"""Fetch and extract version info."""
|
|
462
|
+
if not self.state.session:
|
|
463
|
+
return self._err("Not connected. Use :connect <device>")
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
started = time.time()
|
|
467
|
+
result = self.state.api.send_platform_command(
|
|
468
|
+
self.state.session,
|
|
469
|
+
'version',
|
|
470
|
+
parse=True,
|
|
471
|
+
timeout=30,
|
|
472
|
+
)
|
|
473
|
+
elapsed = time.time() - started
|
|
474
|
+
|
|
475
|
+
if not result:
|
|
476
|
+
return self._err("Version command not available for this platform")
|
|
477
|
+
|
|
478
|
+
# Extract structured version info
|
|
479
|
+
version_info = extract_version_info(
|
|
480
|
+
result.parsed_data,
|
|
481
|
+
self.state.platform_hint or self.state.session.platform
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
payload = result.to_dict()
|
|
485
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
486
|
+
payload["command_type"] = "version"
|
|
487
|
+
payload["version_info"] = version_info
|
|
488
|
+
|
|
489
|
+
return self._ok({"type": "version", "result": payload})
|
|
490
|
+
|
|
491
|
+
except Exception as e:
|
|
492
|
+
return self._err(f"Version command failed: {e}")
|
|
493
|
+
|
|
494
|
+
def _quick_interfaces(self) -> Dict[str, Any]:
|
|
495
|
+
"""Fetch interface status."""
|
|
496
|
+
if not self.state.session:
|
|
497
|
+
return self._err("Not connected. Use :connect <device>")
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
started = time.time()
|
|
501
|
+
result = self.state.api.send_platform_command(
|
|
502
|
+
self.state.session,
|
|
503
|
+
'interfaces_status',
|
|
504
|
+
parse=True,
|
|
505
|
+
timeout=30,
|
|
506
|
+
)
|
|
507
|
+
elapsed = time.time() - started
|
|
508
|
+
|
|
509
|
+
if not result:
|
|
510
|
+
return self._err("Interfaces command not available for this platform")
|
|
511
|
+
|
|
512
|
+
payload = result.to_dict()
|
|
513
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
514
|
+
payload["command_type"] = "interfaces"
|
|
515
|
+
|
|
516
|
+
return self._ok({"type": "interfaces", "result": payload})
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
return self._err(f"Interfaces command failed: {e}")
|
|
520
|
+
|
|
521
|
+
def _quick_neighbors(self) -> Dict[str, Any]:
|
|
522
|
+
"""Fetch CDP/LLDP neighbors with fallback."""
|
|
523
|
+
if not self.state.session:
|
|
524
|
+
return self._err("Not connected. Use :connect <device>")
|
|
525
|
+
|
|
526
|
+
platform = self.state.platform_hint or self.state.session.platform
|
|
527
|
+
|
|
528
|
+
# Build command list - CDP first for Cisco, LLDP first for others
|
|
529
|
+
cdp_cmd = get_platform_command(platform, 'neighbors_cdp')
|
|
530
|
+
lldp_cmd = get_platform_command(platform, 'neighbors_lldp')
|
|
531
|
+
|
|
532
|
+
if platform and 'cisco' in platform:
|
|
533
|
+
commands = [cdp_cmd, lldp_cmd]
|
|
534
|
+
else:
|
|
535
|
+
commands = [lldp_cmd, cdp_cmd]
|
|
536
|
+
|
|
537
|
+
# Filter None commands
|
|
538
|
+
commands = [c for c in commands if c]
|
|
539
|
+
|
|
540
|
+
if not commands:
|
|
541
|
+
return self._err(f"No neighbor discovery commands available for platform '{platform}'")
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
started = time.time()
|
|
545
|
+
result = self.state.api.send_first(
|
|
546
|
+
self.state.session,
|
|
547
|
+
commands,
|
|
548
|
+
parse=True,
|
|
549
|
+
timeout=30,
|
|
550
|
+
require_parsed=True,
|
|
551
|
+
)
|
|
552
|
+
elapsed = time.time() - started
|
|
553
|
+
|
|
554
|
+
if not result:
|
|
555
|
+
# Try without requiring parsed data
|
|
556
|
+
result = self.state.api.send_first(
|
|
557
|
+
self.state.session,
|
|
558
|
+
commands,
|
|
559
|
+
parse=True,
|
|
560
|
+
timeout=30,
|
|
561
|
+
require_parsed=False,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
if not result:
|
|
565
|
+
return self._ok({
|
|
566
|
+
"type": "neighbors",
|
|
567
|
+
"result": {
|
|
568
|
+
"command": "CDP/LLDP",
|
|
569
|
+
"raw_output": "No neighbors found or commands failed",
|
|
570
|
+
"parsed_data": [],
|
|
571
|
+
"elapsed_seconds": round(elapsed, 3),
|
|
572
|
+
}
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
# Extract normalized neighbor info
|
|
576
|
+
neighbors = extract_neighbor_info(result.parsed_data) if result.parsed_data else []
|
|
577
|
+
|
|
578
|
+
payload = result.to_dict()
|
|
579
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
580
|
+
payload["command_type"] = "neighbors"
|
|
581
|
+
payload["neighbor_info"] = neighbors
|
|
582
|
+
|
|
583
|
+
return self._ok({"type": "neighbors", "result": payload})
|
|
584
|
+
|
|
585
|
+
except Exception as e:
|
|
586
|
+
return self._err(f"Neighbor discovery failed: {e}")
|
|
587
|
+
|
|
588
|
+
def _quick_bgp(self) -> Dict[str, Any]:
|
|
589
|
+
"""Fetch BGP summary."""
|
|
590
|
+
if not self.state.session:
|
|
591
|
+
return self._err("Not connected. Use :connect <device>")
|
|
592
|
+
|
|
593
|
+
try:
|
|
594
|
+
started = time.time()
|
|
595
|
+
result = self.state.api.send_platform_command(
|
|
596
|
+
self.state.session,
|
|
597
|
+
'bgp_summary',
|
|
598
|
+
parse=True,
|
|
599
|
+
timeout=30,
|
|
600
|
+
)
|
|
601
|
+
elapsed = time.time() - started
|
|
602
|
+
|
|
603
|
+
if not result:
|
|
604
|
+
return self._err("BGP command not available for this platform")
|
|
605
|
+
|
|
606
|
+
payload = result.to_dict()
|
|
607
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
608
|
+
payload["command_type"] = "bgp"
|
|
609
|
+
|
|
610
|
+
return self._ok({"type": "bgp", "result": payload})
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
return self._err(f"BGP command failed: {e}")
|
|
614
|
+
|
|
615
|
+
def _quick_routes(self) -> Dict[str, Any]:
|
|
616
|
+
"""Fetch routing table."""
|
|
617
|
+
if not self.state.session:
|
|
618
|
+
return self._err("Not connected. Use :connect <device>")
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
started = time.time()
|
|
622
|
+
result = self.state.api.send_platform_command(
|
|
623
|
+
self.state.session,
|
|
624
|
+
'routing_table',
|
|
625
|
+
parse=True,
|
|
626
|
+
timeout=30,
|
|
627
|
+
)
|
|
628
|
+
elapsed = time.time() - started
|
|
629
|
+
|
|
630
|
+
if not result:
|
|
631
|
+
return self._err("Routing command not available for this platform")
|
|
632
|
+
|
|
633
|
+
payload = result.to_dict()
|
|
634
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
635
|
+
payload["command_type"] = "routes"
|
|
636
|
+
|
|
637
|
+
return self._ok({"type": "routes", "result": payload})
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
return self._err(f"Routing command failed: {e}")
|
|
641
|
+
|
|
642
|
+
def _quick_interface_detail(self, interface: str) -> Dict[str, Any]:
|
|
643
|
+
"""Fetch specific interface details."""
|
|
644
|
+
if not self.state.session:
|
|
645
|
+
return self._err("Not connected. Use :connect <device>")
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
started = time.time()
|
|
649
|
+
result = self.state.api.send_platform_command(
|
|
650
|
+
self.state.session,
|
|
651
|
+
'interface_detail',
|
|
652
|
+
name=interface,
|
|
653
|
+
parse=True,
|
|
654
|
+
timeout=30,
|
|
655
|
+
)
|
|
656
|
+
elapsed = time.time() - started
|
|
657
|
+
|
|
658
|
+
if not result:
|
|
659
|
+
return self._err(f"Interface detail command not available for this platform")
|
|
660
|
+
|
|
661
|
+
payload = result.to_dict()
|
|
662
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
663
|
+
payload["command_type"] = "interface_detail"
|
|
664
|
+
payload["interface"] = interface
|
|
665
|
+
|
|
666
|
+
return self._ok({"type": "interface_detail", "result": payload})
|
|
667
|
+
|
|
668
|
+
except Exception as e:
|
|
669
|
+
return self._err(f"Interface detail command failed: {e}")
|
|
670
|
+
|
|
318
671
|
# -----------------------
|
|
319
672
|
# CLI send path
|
|
320
673
|
# -----------------------
|
|
@@ -328,15 +681,15 @@ class NTermREPL:
|
|
|
328
681
|
|
|
329
682
|
try:
|
|
330
683
|
started = time.time()
|
|
331
|
-
|
|
684
|
+
|
|
332
685
|
# Determine if we should parse based on output mode
|
|
333
686
|
should_parse = (self.state.output_mode == "parsed")
|
|
334
|
-
|
|
335
|
-
# Apply platform hint if set
|
|
687
|
+
|
|
688
|
+
# Apply platform hint if set
|
|
336
689
|
original_platform = self.state.session.platform
|
|
337
690
|
if self.state.platform_hint:
|
|
338
691
|
self.state.session.platform = self.state.platform_hint
|
|
339
|
-
|
|
692
|
+
|
|
340
693
|
try:
|
|
341
694
|
res: CommandResult = self.state.api.send(
|
|
342
695
|
self.state.session,
|
|
@@ -349,13 +702,13 @@ class NTermREPL:
|
|
|
349
702
|
# Restore original platform
|
|
350
703
|
if self.state.platform_hint:
|
|
351
704
|
self.state.session.platform = original_platform
|
|
352
|
-
|
|
705
|
+
|
|
353
706
|
elapsed = time.time() - started
|
|
354
707
|
|
|
355
708
|
# Clip raw output for safety/transport
|
|
356
709
|
raw = res.raw_output or ""
|
|
357
710
|
if len(raw) > self.state.policy.max_output_chars:
|
|
358
|
-
raw = raw[:
|
|
711
|
+
raw = raw[:self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
359
712
|
|
|
360
713
|
payload = res.to_dict()
|
|
361
714
|
payload["raw_output"] = raw
|
|
@@ -365,13 +718,18 @@ class NTermREPL:
|
|
|
365
718
|
except Exception as e:
|
|
366
719
|
return self._err(f"Command execution failed: {e}")
|
|
367
720
|
|
|
721
|
+
# -----------------------
|
|
722
|
+
# Helpers
|
|
723
|
+
# -----------------------
|
|
724
|
+
|
|
368
725
|
def _safe_disconnect(self) -> None:
|
|
726
|
+
"""Disconnect current session only (not all sessions)."""
|
|
369
727
|
if self.state.session:
|
|
370
728
|
try:
|
|
371
729
|
self.state.api.disconnect(self.state.session)
|
|
372
730
|
finally:
|
|
373
731
|
self.state.session = None
|
|
374
|
-
self.connected_device = None
|
|
732
|
+
self.state.connected_device = None
|
|
375
733
|
|
|
376
734
|
def do_unlock(self, password: str) -> Dict[str, Any]:
|
|
377
735
|
"""Internal method to perform unlock with password."""
|
|
@@ -383,25 +741,63 @@ class NTermREPL:
|
|
|
383
741
|
return self._err(f"Unlock failed: {e}")
|
|
384
742
|
|
|
385
743
|
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
|
-
|
|
744
|
+
return """
|
|
745
|
+
nterm REPL Commands
|
|
746
|
+
===================
|
|
747
|
+
|
|
748
|
+
Vault:
|
|
749
|
+
:unlock Unlock credential vault (prompts securely)
|
|
750
|
+
:lock Lock credential vault
|
|
751
|
+
|
|
752
|
+
Inventory:
|
|
753
|
+
:creds [pattern] List credentials (supports glob patterns)
|
|
754
|
+
:devices [pattern] List devices [--folder name]
|
|
755
|
+
:folders List all folders
|
|
756
|
+
|
|
757
|
+
Sessions:
|
|
758
|
+
:connect <device> Connect to device [--cred name] [--debug]
|
|
759
|
+
:disconnect Disconnect current session
|
|
760
|
+
:disconnect_all Disconnect all sessions
|
|
761
|
+
:switch <device> Switch to another active session
|
|
762
|
+
:sessions List all active sessions
|
|
763
|
+
|
|
764
|
+
Quick Commands (platform-aware, auto-selects correct syntax):
|
|
765
|
+
:config Fetch running configuration
|
|
766
|
+
:version Fetch and parse version info
|
|
767
|
+
:interfaces Fetch interface status
|
|
768
|
+
:neighbors Fetch CDP/LLDP neighbors (tries both)
|
|
769
|
+
:bgp Fetch BGP summary
|
|
770
|
+
:routes Fetch routing table
|
|
771
|
+
:intf <n> Fetch specific interface details
|
|
772
|
+
|
|
773
|
+
Settings:
|
|
774
|
+
:policy [mode] Get/set policy mode (read_only|ops)
|
|
775
|
+
:mode [raw|parsed] Get/set output mode
|
|
776
|
+
:format [fmt] Get/set display format (text|rich|json)
|
|
777
|
+
:set_hint <platform> Override platform detection (cisco_ios, arista_eos, etc.)
|
|
778
|
+
:clear_hint Use auto-detected platform
|
|
779
|
+
:debug [on|off] Toggle debug mode
|
|
780
|
+
|
|
781
|
+
Info:
|
|
782
|
+
:dbinfo Show TextFSM database status
|
|
783
|
+
:help Show this help
|
|
784
|
+
:exit Disconnect all and exit
|
|
785
|
+
|
|
786
|
+
Raw Commands:
|
|
787
|
+
(anything else) Sends as CLI command to connected device
|
|
788
|
+
|
|
789
|
+
Multi-Session Example:
|
|
790
|
+
:connect spine-1 # Connect to first device
|
|
791
|
+
show version # Run command on spine-1
|
|
792
|
+
:connect spine-2 # Connect to second (spine-1 stays active!)
|
|
793
|
+
show version # Run command on spine-2
|
|
794
|
+
:sessions # See both sessions
|
|
795
|
+
:switch spine-1 # Switch back to spine-1
|
|
796
|
+
show ip route # Run command on spine-1
|
|
797
|
+
:disconnect # Disconnect spine-1, auto-switch to spine-2
|
|
798
|
+
:disconnect_all # Disconnect all remaining sessions
|
|
799
|
+
:exit
|
|
800
|
+
"""
|
|
405
801
|
|
|
406
802
|
def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
407
803
|
return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
|