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/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,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
- :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
+ :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", # example if you don't want interactive spam
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
- if self.state.session:
161
- self._safe_disconnect()
162
- return self._ok({"type": "exit"})
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 <password> first.")
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: List[Dict[str, Any]] = []
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
- 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
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
- if i + 1 < len(parts):
215
- cred = parts[i + 1]
216
- i += 1
217
- i += 1
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 <password> first.")
233
+ return self._err("Vault is locked. Run :unlock first.")
221
234
 
222
- # Single active session policy by default
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 (modify session platform temporarily)
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[: self.state.policy.max_output_chars] + "\n...<truncated>..."
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
- "Commands:\n"
384
- " :unlock (prompts for vault password securely)\n"
385
- " :lock\n"
386
- " :creds [pattern]\n"
387
- " :devices [pattern]\n"
388
- " :connect <device> [--cred name]\n"
389
- " :disconnect\n"
390
- " :policy [read_only|ops]\n"
391
- " :mode [raw|parsed] (control output format, default: parsed)\n"
392
- " :format [text|rich|json] (parsed mode display format, default: text)\n"
393
- " :set_hint <platform> (override TextFSM platform, e.g., cisco_ios)\n"
394
- " :clear_hint (use auto-detected platform)\n"
395
- " :debug [on|off] (show full result data for troubleshooting)\n"
396
- " :dbinfo (show TextFSM database status)\n"
397
- " (anything else runs as CLI on the connected session)\n"
398
- " :help\n"
399
- " :exit\n"
400
- )
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()}