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/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,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 <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
 
235
+ # Disconnect existing session if any
222
236
  if self.state.session:
223
237
  self._safe_disconnect()
224
238
 
225
239
  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
- )
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 (modify session platform temporarily)
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[: self.state.policy.max_output_chars] + "\n...<truncated>..."
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
- "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
- )
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()}