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/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
- i = 0
38
- while i < len(self.deny_substrings):
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
- # Cheap "write reminder": block common config verbs
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
- j = 0
66
- while j < len(write_verbs):
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
- 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
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
- :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
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", # example if you don't want interactive spam
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
- if self.state.session:
161
- self._safe_disconnect()
162
- return self._ok({"type": "exit"})
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 <password> first.")
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: List[Dict[str, Any]] = []
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
- 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
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
- if i + 1 < len(parts):
215
- cred = parts[i + 1]
216
- i += 1
217
- i += 1
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 <password> first.")
221
-
222
- if self.state.session:
223
- self._safe_disconnect()
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
- # Pass debug flag from REPL state
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 'raw' or 'parsed'")
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 'text', 'rich', or 'json'")
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
- platform = parts[1].lower()
288
- self.state.platform_hint = platform
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) >= 2:
297
- mode = parts[1].lower()
298
- if mode in ["on", "true", "1"]:
299
- self.state.debug_mode = True
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
- try:
311
- db_info = self.state.api.db_info()
312
- return self._ok({"type": "dbinfo", "db_info": db_info})
313
- except Exception as e:
314
- return self._err(f"Failed to get DB info: {e}")
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 (modify session platform temporarily)
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[: self.state.policy.max_output_chars] + "\n...<truncated>..."
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
- "Commands:\n"
388
- " :unlock (prompts for vault password securely)\n"
389
- " :lock\n"
390
- " :creds [pattern]\n"
391
- " :devices [pattern]\n"
392
- " :connect <device> [--cred name]\n"
393
- " :disconnect\n"
394
- " :policy [read_only|ops]\n"
395
- " :mode [raw|parsed] (control output format, default: parsed)\n"
396
- " :format [text|rich|json] (parsed mode display format, default: text)\n"
397
- " :set_hint <platform> (override TextFSM platform, e.g., cisco_ios)\n"
398
- " :clear_hint (use auto-detected platform)\n"
399
- " :debug [on|off] (show full result data for troubleshooting)\n"
400
- " :dbinfo (show TextFSM database status)\n"
401
- " (anything else runs as CLI on the connected session)\n"
402
- " :help\n"
403
- " :exit\n"
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()}