ntermqt 0.1.5__tar.gz → 0.1.6__tar.gz
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.
- {ntermqt-0.1.5/ntermqt.egg-info → ntermqt-0.1.6}/PKG-INFO +4 -1
- {ntermqt-0.1.5 → ntermqt-0.1.6}/README.md +2 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/__init__.py +8 -6
- ntermqt-0.1.6/nterm/scripting/repl.py +406 -0
- ntermqt-0.1.6/nterm/scripting/repl_interactive.py +418 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/local_terminal.py +1 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6/ntermqt.egg-info}/PKG-INFO +4 -1
- {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/SOURCES.txt +2 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/requires.txt +1 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/pyproject.toml +2 -1
- {ntermqt-0.1.5 → ntermqt-0.1.6}/MANIFEST.in +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/__main__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/askpass/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/askpass/server.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/config.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/connection/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/connection/profile.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/connect_dialog.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/editor.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/io.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/models.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/settings.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/manager/tree.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/api_help_dialog.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/ntc_download_dialog.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_engine.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_fire.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/parser/tfsm_fire_tester.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/resources.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/api.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/scripting/cli.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/askpass_ssh.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/base.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/interactive_ssh.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/pty_transport.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/session/ssh.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/bridge.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.html +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/terminal.js +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-fit.min.js +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-unicode11.min.js +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm-addon-web-links.min.js +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.css +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/resources/xterm.min.js +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/terminal/widget.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/engine.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/stylesheet.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/clean.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/default.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/dracula.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_dark.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_hybrid.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/enterprise_light.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_dark.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_hybrid.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/gruvbox_light.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/theme/themes/nord_hybrid.yaml +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/__init__.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/credential_manager.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/keychain.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/manager_ui.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/profile.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/resolver.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/nterm/vault/store.py +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/dependency_links.txt +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/entry_points.txt +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/ntermqt.egg-info/top_level.txt +0 -0
- {ntermqt-0.1.5 → ntermqt-0.1.6}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntermqt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
|
|
5
5
|
Author: Scott Peterman
|
|
6
6
|
License: GPL-3.0
|
|
@@ -29,6 +29,7 @@ Requires-Dist: click>=8.0.0
|
|
|
29
29
|
Requires-Dist: ipython>=8.0.0
|
|
30
30
|
Requires-Dist: requests>=2.10.0
|
|
31
31
|
Requires-Dist: textfsm>=2.0.0
|
|
32
|
+
Requires-Dist: rich>=14.0.0
|
|
32
33
|
Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
|
|
33
34
|
Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
|
|
34
35
|
Provides-Extra: keyring
|
|
@@ -110,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
110
111
|
|
|
111
112
|

|
|
112
113
|
|
|
114
|
+

|
|
115
|
+
|
|
113
116
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
114
117
|
|
|
115
118
|
```python
|
|
@@ -66,6 +66,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
66
66
|
|
|
67
67
|

|
|
68
68
|
|
|
69
|
+

|
|
70
|
+
|
|
69
71
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
70
72
|
|
|
71
73
|
```python
|
|
@@ -15,11 +15,6 @@ Quick Start (IPython):
|
|
|
15
15
|
api.credentials() # List credentials
|
|
16
16
|
|
|
17
17
|
api.help() # Show all commands
|
|
18
|
-
|
|
19
|
-
Quick Start (CLI):
|
|
20
|
-
nterm-cli devices
|
|
21
|
-
nterm-cli search leaf
|
|
22
|
-
nterm-cli credentials --unlock
|
|
23
18
|
"""
|
|
24
19
|
|
|
25
20
|
from .api import (
|
|
@@ -29,9 +24,14 @@ from .api import (
|
|
|
29
24
|
get_api,
|
|
30
25
|
reset_api,
|
|
31
26
|
)
|
|
27
|
+
from nterm.scripting.repl import REPLPolicy, NTermREPL
|
|
28
|
+
from nterm.scripting.repl_interactive import add_repl_to_api
|
|
32
29
|
|
|
33
30
|
# Convenience: pre-instantiated API
|
|
34
|
-
api = get_api()
|
|
31
|
+
api = get_api() # <-- This FIRST
|
|
32
|
+
|
|
33
|
+
# Make api.repl() available
|
|
34
|
+
add_repl_to_api(api) # <-- Then this
|
|
35
35
|
|
|
36
36
|
__all__ = [
|
|
37
37
|
"NTermAPI",
|
|
@@ -40,4 +40,6 @@ __all__ = [
|
|
|
40
40
|
"get_api",
|
|
41
41
|
"reset_api",
|
|
42
42
|
"api",
|
|
43
|
+
'REPLPolicy',
|
|
44
|
+
'NTermREPL'
|
|
43
45
|
]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# nterm/scripting/repl.py
|
|
2
|
+
#
|
|
3
|
+
# A single "front door" that BOTH humans (GUI REPL) and MCP use.
|
|
4
|
+
# Guardrails live here, not in the agent.
|
|
5
|
+
#
|
|
6
|
+
# - Allow-list commands (or allow-list verbs + deny-list verbs)
|
|
7
|
+
# - Optional read-only mode
|
|
8
|
+
# - Session scoping (one device/session per REPL unless explicitly allowed)
|
|
9
|
+
# - Audit log of everything that ran
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import shlex
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Optional, Dict, Any, List
|
|
20
|
+
|
|
21
|
+
from .api import NTermAPI, ActiveSession, CommandResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class REPLPolicy:
|
|
26
|
+
mode: str = "read_only" # "read_only" or "ops"
|
|
27
|
+
max_output_chars: int = 250000
|
|
28
|
+
max_command_seconds: int = 60
|
|
29
|
+
|
|
30
|
+
# Simple and surprisingly effective "don't brick the network" guardrails
|
|
31
|
+
deny_substrings: List[str] = field(default_factory=list)
|
|
32
|
+
allow_prefixes: List[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def is_allowed(self, command: str) -> bool:
|
|
35
|
+
cmd = command.strip().lower()
|
|
36
|
+
|
|
37
|
+
i = 0
|
|
38
|
+
while i < len(self.deny_substrings):
|
|
39
|
+
bad = self.deny_substrings[i].lower()
|
|
40
|
+
if bad in cmd:
|
|
41
|
+
return False
|
|
42
|
+
i += 1
|
|
43
|
+
|
|
44
|
+
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)
|
|
47
|
+
write_verbs = [
|
|
48
|
+
"conf t",
|
|
49
|
+
"configure",
|
|
50
|
+
"copy ",
|
|
51
|
+
"write",
|
|
52
|
+
"wr ",
|
|
53
|
+
"reload",
|
|
54
|
+
"commit",
|
|
55
|
+
"delete",
|
|
56
|
+
"set ",
|
|
57
|
+
"unset ",
|
|
58
|
+
"clear ",
|
|
59
|
+
"shutdown",
|
|
60
|
+
"no shutdown",
|
|
61
|
+
"format",
|
|
62
|
+
"upgrade",
|
|
63
|
+
"install",
|
|
64
|
+
]
|
|
65
|
+
j = 0
|
|
66
|
+
while j < len(write_verbs):
|
|
67
|
+
if cmd.startswith(write_verbs[j]):
|
|
68
|
+
return False
|
|
69
|
+
j += 1
|
|
70
|
+
|
|
71
|
+
# If allow_prefixes provided, require one of them
|
|
72
|
+
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
|
|
82
|
+
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class REPLState:
|
|
88
|
+
api: NTermAPI
|
|
89
|
+
policy: REPLPolicy
|
|
90
|
+
vault_unlocked: bool = False
|
|
91
|
+
session: Optional[ActiveSession] = None
|
|
92
|
+
connected_device: Optional[str] = None
|
|
93
|
+
output_mode: str = "parsed" # "raw" or "parsed"
|
|
94
|
+
output_format: str = "text" # "text", "rich", or "json" (for parsed mode only)
|
|
95
|
+
platform_hint: Optional[str] = None # Override platform for TextFSM
|
|
96
|
+
debug_mode: bool = False # Show full result data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class NTermREPL:
|
|
100
|
+
"""
|
|
101
|
+
A minimal command router. This is the "tool surface".
|
|
102
|
+
MCP can call `handle_line()`; humans can type into it.
|
|
103
|
+
|
|
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
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
|
|
124
|
+
if api is None:
|
|
125
|
+
api = NTermAPI()
|
|
126
|
+
if policy is None:
|
|
127
|
+
policy = REPLPolicy(
|
|
128
|
+
mode="read_only",
|
|
129
|
+
deny_substrings=[
|
|
130
|
+
"terminal monitor", # example if you don't want interactive spam
|
|
131
|
+
],
|
|
132
|
+
allow_prefixes=[],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.state = REPLState(api=api, policy=policy)
|
|
136
|
+
|
|
137
|
+
def handle_line(self, line: str) -> Dict[str, Any]:
|
|
138
|
+
line = (line or "").strip()
|
|
139
|
+
if not line:
|
|
140
|
+
return self._ok({"type": "noop"})
|
|
141
|
+
|
|
142
|
+
if line.startswith(":"):
|
|
143
|
+
return self._handle_meta(line)
|
|
144
|
+
|
|
145
|
+
# Default: treat as CLI to send
|
|
146
|
+
return self._handle_send(line)
|
|
147
|
+
|
|
148
|
+
# -----------------------
|
|
149
|
+
# Meta / REPL commands
|
|
150
|
+
# -----------------------
|
|
151
|
+
|
|
152
|
+
def _handle_meta(self, line: str) -> Dict[str, Any]:
|
|
153
|
+
parts = shlex.split(line)
|
|
154
|
+
cmd = parts[0].lower()
|
|
155
|
+
|
|
156
|
+
if cmd == ":help":
|
|
157
|
+
return self._ok({"type": "help", "text": self._help_text()})
|
|
158
|
+
|
|
159
|
+
if cmd == ":exit":
|
|
160
|
+
if self.state.session:
|
|
161
|
+
self._safe_disconnect()
|
|
162
|
+
return self._ok({"type": "exit"})
|
|
163
|
+
|
|
164
|
+
if cmd == ":unlock":
|
|
165
|
+
# Password should be provided separately, not in the command line
|
|
166
|
+
if len(parts) > 1:
|
|
167
|
+
return self._err(":unlock takes no arguments. Password will be prompted securely.")
|
|
168
|
+
return self._ok({"type": "unlock_prompt", "message": "Please provide vault password"})
|
|
169
|
+
|
|
170
|
+
if cmd == ":lock":
|
|
171
|
+
self.state.api.lock()
|
|
172
|
+
self.state.vault_unlocked = False
|
|
173
|
+
return self._ok({"type": "lock", "vault_unlocked": False})
|
|
174
|
+
|
|
175
|
+
if cmd == ":creds":
|
|
176
|
+
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]
|
|
182
|
+
|
|
183
|
+
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
|
|
189
|
+
return self._ok({"type": "credentials", "credentials": rows})
|
|
190
|
+
|
|
191
|
+
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
|
|
202
|
+
return self._ok({"type": "devices", "devices": rows})
|
|
203
|
+
|
|
204
|
+
if cmd == ":connect":
|
|
205
|
+
if len(parts) < 2:
|
|
206
|
+
return self._err("Usage: :connect <device> [--cred name]")
|
|
207
|
+
|
|
208
|
+
device = parts[1]
|
|
209
|
+
cred = None
|
|
210
|
+
|
|
211
|
+
i = 2
|
|
212
|
+
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
|
|
218
|
+
|
|
219
|
+
if not self.state.api.vault_unlocked:
|
|
220
|
+
return self._err("Vault is locked. Run :unlock <password> first.")
|
|
221
|
+
|
|
222
|
+
# Single active session policy by default
|
|
223
|
+
if self.state.session:
|
|
224
|
+
self._safe_disconnect()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
sess = self.state.api.connect(device, credential=cred)
|
|
228
|
+
self.state.session = sess
|
|
229
|
+
self.state.connected_device = sess.device_name
|
|
230
|
+
|
|
231
|
+
return self._ok({
|
|
232
|
+
"type": "connect",
|
|
233
|
+
"device": sess.device_name,
|
|
234
|
+
"hostname": sess.hostname,
|
|
235
|
+
"port": sess.port,
|
|
236
|
+
"platform": sess.platform,
|
|
237
|
+
"prompt": sess.prompt,
|
|
238
|
+
})
|
|
239
|
+
except Exception as e:
|
|
240
|
+
return self._err(f"Connection failed: {e}")
|
|
241
|
+
|
|
242
|
+
if cmd == ":disconnect":
|
|
243
|
+
self._safe_disconnect()
|
|
244
|
+
return self._ok({"type": "disconnect"})
|
|
245
|
+
|
|
246
|
+
if cmd == ":policy":
|
|
247
|
+
if len(parts) < 2:
|
|
248
|
+
return self._ok({"type": "policy", "mode": self.state.policy.mode})
|
|
249
|
+
mode = parts[1].lower()
|
|
250
|
+
if mode not in ["read_only", "ops"]:
|
|
251
|
+
return self._err("Policy must be read_only or ops")
|
|
252
|
+
self.state.policy.mode = mode
|
|
253
|
+
return self._ok({"type": "policy", "mode": mode})
|
|
254
|
+
|
|
255
|
+
if cmd == ":mode":
|
|
256
|
+
if len(parts) < 2:
|
|
257
|
+
return self._ok({
|
|
258
|
+
"type": "mode",
|
|
259
|
+
"mode": self.state.output_mode,
|
|
260
|
+
"platform_hint": self.state.platform_hint,
|
|
261
|
+
})
|
|
262
|
+
mode = parts[1].lower()
|
|
263
|
+
if mode not in ["raw", "parsed"]:
|
|
264
|
+
return self._err("Mode must be 'raw' or 'parsed'")
|
|
265
|
+
self.state.output_mode = mode
|
|
266
|
+
return self._ok({"type": "mode", "mode": mode})
|
|
267
|
+
|
|
268
|
+
if cmd == ":format":
|
|
269
|
+
if len(parts) < 2:
|
|
270
|
+
return self._ok({
|
|
271
|
+
"type": "format",
|
|
272
|
+
"format": self.state.output_format,
|
|
273
|
+
})
|
|
274
|
+
fmt = parts[1].lower()
|
|
275
|
+
if fmt not in ["text", "rich", "json"]:
|
|
276
|
+
return self._err("Format must be 'text', 'rich', or 'json'")
|
|
277
|
+
self.state.output_format = fmt
|
|
278
|
+
return self._ok({"type": "format", "format": fmt})
|
|
279
|
+
|
|
280
|
+
if cmd == ":set_hint":
|
|
281
|
+
if len(parts) < 2:
|
|
282
|
+
return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
|
|
283
|
+
platform = parts[1].lower()
|
|
284
|
+
self.state.platform_hint = platform
|
|
285
|
+
return self._ok({"type": "set_hint", "platform_hint": platform})
|
|
286
|
+
|
|
287
|
+
if cmd == ":clear_hint":
|
|
288
|
+
self.state.platform_hint = None
|
|
289
|
+
return self._ok({"type": "clear_hint"})
|
|
290
|
+
|
|
291
|
+
if cmd == ":debug":
|
|
292
|
+
if len(parts) >= 2:
|
|
293
|
+
mode = parts[1].lower()
|
|
294
|
+
if mode in ["on", "true", "1"]:
|
|
295
|
+
self.state.debug_mode = True
|
|
296
|
+
elif mode in ["off", "false", "0"]:
|
|
297
|
+
self.state.debug_mode = False
|
|
298
|
+
else:
|
|
299
|
+
return self._err("Debug mode must be on or off")
|
|
300
|
+
else:
|
|
301
|
+
# Toggle
|
|
302
|
+
self.state.debug_mode = not self.state.debug_mode
|
|
303
|
+
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
304
|
+
|
|
305
|
+
if cmd == ":dbinfo":
|
|
306
|
+
try:
|
|
307
|
+
db_info = self.state.api.db_info()
|
|
308
|
+
return self._ok({"type": "dbinfo", "db_info": db_info})
|
|
309
|
+
except Exception as e:
|
|
310
|
+
return self._err(f"Failed to get DB info: {e}")
|
|
311
|
+
|
|
312
|
+
return self._err(f"Unknown REPL command: {cmd}")
|
|
313
|
+
|
|
314
|
+
# -----------------------
|
|
315
|
+
# CLI send path
|
|
316
|
+
# -----------------------
|
|
317
|
+
|
|
318
|
+
def _handle_send(self, cli: str) -> Dict[str, Any]:
|
|
319
|
+
if not self.state.session:
|
|
320
|
+
return self._err("Not connected. Use :connect <device>")
|
|
321
|
+
|
|
322
|
+
if not self.state.policy.is_allowed(cli):
|
|
323
|
+
return self._err(f"Blocked by policy ({self.state.policy.mode}): {cli}")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
started = time.time()
|
|
327
|
+
|
|
328
|
+
# Determine if we should parse based on output mode
|
|
329
|
+
should_parse = (self.state.output_mode == "parsed")
|
|
330
|
+
|
|
331
|
+
# Apply platform hint if set (modify session platform temporarily)
|
|
332
|
+
original_platform = self.state.session.platform
|
|
333
|
+
if self.state.platform_hint:
|
|
334
|
+
self.state.session.platform = self.state.platform_hint
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
res: CommandResult = self.state.api.send(
|
|
338
|
+
self.state.session,
|
|
339
|
+
cli,
|
|
340
|
+
timeout=self.state.policy.max_command_seconds,
|
|
341
|
+
parse=should_parse,
|
|
342
|
+
normalize=True,
|
|
343
|
+
)
|
|
344
|
+
finally:
|
|
345
|
+
# Restore original platform
|
|
346
|
+
if self.state.platform_hint:
|
|
347
|
+
self.state.session.platform = original_platform
|
|
348
|
+
|
|
349
|
+
elapsed = time.time() - started
|
|
350
|
+
|
|
351
|
+
# Clip raw output for safety/transport
|
|
352
|
+
raw = res.raw_output or ""
|
|
353
|
+
if len(raw) > self.state.policy.max_output_chars:
|
|
354
|
+
raw = raw[: self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
355
|
+
|
|
356
|
+
payload = res.to_dict()
|
|
357
|
+
payload["raw_output"] = raw
|
|
358
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
359
|
+
|
|
360
|
+
return self._ok({"type": "result", "result": payload})
|
|
361
|
+
except Exception as e:
|
|
362
|
+
return self._err(f"Command execution failed: {e}")
|
|
363
|
+
|
|
364
|
+
def _safe_disconnect(self) -> None:
|
|
365
|
+
if self.state.session:
|
|
366
|
+
try:
|
|
367
|
+
self.state.api.disconnect(self.state.session)
|
|
368
|
+
finally:
|
|
369
|
+
self.state.session = None
|
|
370
|
+
self.connected_device = None
|
|
371
|
+
|
|
372
|
+
def do_unlock(self, password: str) -> Dict[str, Any]:
|
|
373
|
+
"""Internal method to perform unlock with password."""
|
|
374
|
+
try:
|
|
375
|
+
ok = self.state.api.unlock(password)
|
|
376
|
+
self.state.vault_unlocked = bool(ok)
|
|
377
|
+
return self._ok({"type": "unlock", "vault_unlocked": self.state.vault_unlocked})
|
|
378
|
+
except Exception as e:
|
|
379
|
+
return self._err(f"Unlock failed: {e}")
|
|
380
|
+
|
|
381
|
+
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
|
+
)
|
|
401
|
+
|
|
402
|
+
def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
403
|
+
return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
|
|
404
|
+
|
|
405
|
+
def _err(self, message: str) -> Dict[str, Any]:
|
|
406
|
+
return {"ok": False, "error": message, "ts": datetime.now().isoformat()}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nterm Interactive REPL
|
|
3
|
+
|
|
4
|
+
Launch with: api.repl()
|
|
5
|
+
|
|
6
|
+
Provides a safe, policy-controlled interface to network devices.
|
|
7
|
+
Same interface used by both humans (IPython) and MCP tools.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from nterm.scripting.repl import NTermREPL, REPLPolicy
|
|
11
|
+
from .api import NTermAPI
|
|
12
|
+
from typing import Optional
|
|
13
|
+
import sys
|
|
14
|
+
import getpass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def start_repl(api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
|
|
18
|
+
"""
|
|
19
|
+
Start interactive REPL in IPython or terminal.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
api: NTermAPI instance (creates default if None)
|
|
23
|
+
policy: REPLPolicy (uses read_only default if None)
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
# Default read-only mode
|
|
27
|
+
api.repl()
|
|
28
|
+
|
|
29
|
+
# Operations mode (allows config changes)
|
|
30
|
+
policy = REPLPolicy(mode="ops")
|
|
31
|
+
api.repl(policy=policy)
|
|
32
|
+
|
|
33
|
+
# Custom policy
|
|
34
|
+
policy = REPLPolicy(
|
|
35
|
+
mode="read_only",
|
|
36
|
+
deny_substrings=["reload", "wr"],
|
|
37
|
+
allow_prefixes=["show", "display"],
|
|
38
|
+
)
|
|
39
|
+
api.repl(policy=policy)
|
|
40
|
+
"""
|
|
41
|
+
if api is None:
|
|
42
|
+
from nterm.scripting import api as default_api
|
|
43
|
+
api = default_api
|
|
44
|
+
|
|
45
|
+
repl = NTermREPL(api=api, policy=policy)
|
|
46
|
+
|
|
47
|
+
print()
|
|
48
|
+
print("=" * 60)
|
|
49
|
+
print("nterm REPL - Safe Network Automation Interface")
|
|
50
|
+
print("=" * 60)
|
|
51
|
+
print()
|
|
52
|
+
print(f"Policy: {repl.state.policy.mode}")
|
|
53
|
+
print(f"Output: {repl.state.output_mode} ({repl.state.output_format})")
|
|
54
|
+
print(f"Vault: {'unlocked' if api.vault_unlocked else 'locked'}")
|
|
55
|
+
|
|
56
|
+
# Check TextFSM database health
|
|
57
|
+
try:
|
|
58
|
+
db_info = api.db_info()
|
|
59
|
+
db_size = db_info.get('db_size', 0)
|
|
60
|
+
if not db_info.get('db_exists'):
|
|
61
|
+
print(f"\n⚠️ TextFSM database not found!")
|
|
62
|
+
print(f" Parsing will be unavailable. Use :dbinfo for details.")
|
|
63
|
+
elif db_size == 0:
|
|
64
|
+
print(f"\n⚠️ TextFSM database is empty (0 bytes)!")
|
|
65
|
+
print(f" Parsing will fail. Use :dbinfo for details.")
|
|
66
|
+
elif db_size < 100000:
|
|
67
|
+
print(f"\n⚠️ TextFSM database seems small ({db_info.get('db_size_mb', 0):.1f} MB)")
|
|
68
|
+
print(f" Expected ~0.3 MB. Use :dbinfo to check.")
|
|
69
|
+
except Exception:
|
|
70
|
+
# Don't crash startup if db_info fails
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
print()
|
|
74
|
+
print("Type :help for commands, :exit to quit")
|
|
75
|
+
print()
|
|
76
|
+
|
|
77
|
+
# Interactive loop
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
# Show prompt with mode indicator
|
|
81
|
+
if repl.state.connected_device:
|
|
82
|
+
mode_indicator = "📊" if repl.state.output_mode == "parsed" else "📄"
|
|
83
|
+
hint = f"[{repl.state.platform_hint}]" if repl.state.platform_hint else ""
|
|
84
|
+
prompt = f"{mode_indicator} {repl.state.connected_device}{hint}> "
|
|
85
|
+
else:
|
|
86
|
+
prompt = "nterm> "
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
line = input(prompt)
|
|
90
|
+
except EOFError:
|
|
91
|
+
# Ctrl+D
|
|
92
|
+
break
|
|
93
|
+
except KeyboardInterrupt:
|
|
94
|
+
# Ctrl+C
|
|
95
|
+
print()
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Handle command
|
|
99
|
+
result = repl.handle_line(line)
|
|
100
|
+
|
|
101
|
+
# Display result
|
|
102
|
+
if not result.get("ok"):
|
|
103
|
+
print(f"Error: {result.get('error')}")
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
data = result.get("data", {})
|
|
107
|
+
cmd_type = data.get("type")
|
|
108
|
+
|
|
109
|
+
if cmd_type == "exit":
|
|
110
|
+
break
|
|
111
|
+
elif cmd_type == "unlock_prompt":
|
|
112
|
+
# Securely prompt for password
|
|
113
|
+
try:
|
|
114
|
+
password = getpass.getpass("Enter vault password: ")
|
|
115
|
+
unlock_result = repl.do_unlock(password)
|
|
116
|
+
if unlock_result.get("ok"):
|
|
117
|
+
unlock_data = unlock_result.get("data", {})
|
|
118
|
+
if unlock_data.get("vault_unlocked"):
|
|
119
|
+
print("Vault unlocked")
|
|
120
|
+
else:
|
|
121
|
+
print("Unlock failed - incorrect password")
|
|
122
|
+
else:
|
|
123
|
+
print(f"Error: {unlock_result.get('error')}")
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
print("\nUnlock cancelled")
|
|
126
|
+
continue
|
|
127
|
+
elif cmd_type == "help":
|
|
128
|
+
print(data.get("text", ""))
|
|
129
|
+
elif cmd_type == "unlock":
|
|
130
|
+
status = "unlocked" if data.get("vault_unlocked") else "failed"
|
|
131
|
+
print(f"Vault: {status}")
|
|
132
|
+
elif cmd_type == "lock":
|
|
133
|
+
print("Vault locked")
|
|
134
|
+
elif cmd_type == "credentials":
|
|
135
|
+
creds = data.get("credentials", [])
|
|
136
|
+
if not creds:
|
|
137
|
+
print("No credentials found")
|
|
138
|
+
else:
|
|
139
|
+
print(f"\n{'Name':<20} {'Username':<20} {'Type':<15}")
|
|
140
|
+
print("-" * 55)
|
|
141
|
+
for cred in creds:
|
|
142
|
+
print(f"{cred['name']:<20} {cred.get('username', ''):<20} {cred.get('cred_type', 'ssh'):<15}")
|
|
143
|
+
print()
|
|
144
|
+
elif cmd_type == "devices":
|
|
145
|
+
devices = data.get("devices", [])
|
|
146
|
+
if not devices:
|
|
147
|
+
print("No devices found")
|
|
148
|
+
else:
|
|
149
|
+
print(f"\n{'Name':<20} {'Hostname':<20} {'Folder':<15}")
|
|
150
|
+
print("-" * 55)
|
|
151
|
+
for dev in devices:
|
|
152
|
+
print(f"{dev['name']:<20} {dev['hostname']:<20} {dev.get('folder', ''):<15}")
|
|
153
|
+
print()
|
|
154
|
+
elif cmd_type == "connect":
|
|
155
|
+
print(f"Connected to {data['device']} ({data['hostname']}:{data['port']})")
|
|
156
|
+
print(f"Platform: {data.get('platform', 'unknown')}")
|
|
157
|
+
print(f"Prompt: {data.get('prompt', '')}")
|
|
158
|
+
elif cmd_type == "disconnect":
|
|
159
|
+
print("Disconnected")
|
|
160
|
+
elif cmd_type == "policy":
|
|
161
|
+
print(f"Policy mode: {data.get('mode')}")
|
|
162
|
+
elif cmd_type == "mode":
|
|
163
|
+
mode = data.get('mode')
|
|
164
|
+
hint = data.get('platform_hint')
|
|
165
|
+
if mode:
|
|
166
|
+
print(f"Output mode: {mode}")
|
|
167
|
+
else:
|
|
168
|
+
print(f"Current mode: {mode}")
|
|
169
|
+
if hint:
|
|
170
|
+
print(f"Platform hint: {hint}")
|
|
171
|
+
elif cmd_type == "format":
|
|
172
|
+
fmt = data.get('format')
|
|
173
|
+
print(f"Output format: {fmt}")
|
|
174
|
+
elif cmd_type == "set_hint":
|
|
175
|
+
print(f"Platform hint set to: {data.get('platform_hint')}")
|
|
176
|
+
elif cmd_type == "clear_hint":
|
|
177
|
+
print("Platform hint cleared (using auto-detection)")
|
|
178
|
+
elif cmd_type == "debug":
|
|
179
|
+
status = "ON" if data.get("debug_mode") else "OFF"
|
|
180
|
+
print(f"Debug mode: {status}")
|
|
181
|
+
elif cmd_type == "dbinfo":
|
|
182
|
+
db_info = data.get("db_info", {})
|
|
183
|
+
print("\nTextFSM Database Info:")
|
|
184
|
+
print("=" * 60)
|
|
185
|
+
print(f"Engine Available: {db_info.get('engine_available', False)}")
|
|
186
|
+
print(f"Database Path: {db_info.get('db_path', 'unknown')}")
|
|
187
|
+
print(f"Database Exists: {db_info.get('db_exists', False)}")
|
|
188
|
+
|
|
189
|
+
if db_info.get('db_exists'):
|
|
190
|
+
db_size = db_info.get('db_size', 0)
|
|
191
|
+
db_size_mb = db_info.get('db_size_mb', 0.0)
|
|
192
|
+
print(f"Database Size: {db_size:,} bytes ({db_size_mb:.1f} MB)")
|
|
193
|
+
print(f"Absolute Path: {db_info.get('db_absolute_path', 'unknown')}")
|
|
194
|
+
|
|
195
|
+
# Health checks
|
|
196
|
+
if db_size == 0:
|
|
197
|
+
print("\n⚠️ WARNING: Database file is empty (0 bytes)!")
|
|
198
|
+
print(" Parsing will fail until you download templates.")
|
|
199
|
+
print(" Run: api.download_templates() or use the templates installer.")
|
|
200
|
+
elif db_size < 100000: # Less than 100KB
|
|
201
|
+
print(f"\n⚠️ WARNING: Database seems too small ({db_size_mb:.1f} MB)")
|
|
202
|
+
print(" Expected size is ~0.3 MB. May be corrupted or incomplete.")
|
|
203
|
+
else:
|
|
204
|
+
print("\n✓ Database appears healthy")
|
|
205
|
+
else:
|
|
206
|
+
print("\n❌ ERROR: Database file not found!")
|
|
207
|
+
print(" Run: api.download_templates() to create it.")
|
|
208
|
+
|
|
209
|
+
print()
|
|
210
|
+
elif cmd_type == "result":
|
|
211
|
+
result_data = data.get("result", {})
|
|
212
|
+
|
|
213
|
+
# Debug mode: show full result dict
|
|
214
|
+
if repl.state.debug_mode:
|
|
215
|
+
print("\n[DEBUG - Full Result Dict]")
|
|
216
|
+
print("-" * 60)
|
|
217
|
+
import json
|
|
218
|
+
# Don't print raw_output in debug to avoid clutter
|
|
219
|
+
debug_data = {k: v for k, v in result_data.items() if k != "raw_output"}
|
|
220
|
+
print(json.dumps(debug_data, indent=2))
|
|
221
|
+
print("-" * 60)
|
|
222
|
+
|
|
223
|
+
# Show parsed data if available
|
|
224
|
+
parsed = result_data.get("parsed_data")
|
|
225
|
+
parse_success = result_data.get("parse_success", False)
|
|
226
|
+
platform = result_data.get("platform", "")
|
|
227
|
+
|
|
228
|
+
# Display based on mode and format
|
|
229
|
+
if repl.state.output_mode == "parsed":
|
|
230
|
+
if parsed and parse_success:
|
|
231
|
+
print(f"\n[Parsed with {platform} - format: {repl.state.output_format}]")
|
|
232
|
+
print("-" * 60)
|
|
233
|
+
_display_parsed_result(parsed, repl.state.output_format)
|
|
234
|
+
print()
|
|
235
|
+
elif parse_success and not parsed:
|
|
236
|
+
# Parsing succeeded but returned empty/no data
|
|
237
|
+
print(f"\n[Parsed with {platform} - no structured data]")
|
|
238
|
+
raw = result_data.get("raw_output", "")
|
|
239
|
+
print(raw)
|
|
240
|
+
elif parsed is None and not parse_success:
|
|
241
|
+
# Parsing failed or wasn't attempted
|
|
242
|
+
print(f"\n[Parse failed - showing raw output]")
|
|
243
|
+
raw = result_data.get("raw_output", "")
|
|
244
|
+
print(raw)
|
|
245
|
+
else:
|
|
246
|
+
# Fallback
|
|
247
|
+
raw = result_data.get("raw_output", "")
|
|
248
|
+
print(raw)
|
|
249
|
+
else:
|
|
250
|
+
# Raw mode - just show output
|
|
251
|
+
raw = result_data.get("raw_output", "")
|
|
252
|
+
print(raw)
|
|
253
|
+
|
|
254
|
+
# Show timing
|
|
255
|
+
elapsed = result_data.get("elapsed_seconds", 0)
|
|
256
|
+
print(f"\n[{elapsed}s]")
|
|
257
|
+
elif cmd_type == "noop":
|
|
258
|
+
pass
|
|
259
|
+
else:
|
|
260
|
+
# Unknown type, show raw data
|
|
261
|
+
import json
|
|
262
|
+
print(json.dumps(data, indent=2))
|
|
263
|
+
|
|
264
|
+
finally:
|
|
265
|
+
# Clean up
|
|
266
|
+
if repl.state.session:
|
|
267
|
+
print("\nDisconnecting...")
|
|
268
|
+
repl._safe_disconnect()
|
|
269
|
+
print("\nREPL closed")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _print_parsed_data(data, max_rows=20):
|
|
273
|
+
"""Pretty print parsed data (list of dicts) in text format."""
|
|
274
|
+
if not data:
|
|
275
|
+
print("(empty)")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
if not isinstance(data, list):
|
|
279
|
+
import json
|
|
280
|
+
print(json.dumps(data, indent=2))
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# Get all unique keys
|
|
284
|
+
all_keys = set()
|
|
285
|
+
for row in data:
|
|
286
|
+
if isinstance(row, dict):
|
|
287
|
+
all_keys.update(row.keys())
|
|
288
|
+
|
|
289
|
+
keys = sorted(all_keys)
|
|
290
|
+
|
|
291
|
+
if not keys:
|
|
292
|
+
import json
|
|
293
|
+
print(json.dumps(data, indent=2))
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Calculate column widths
|
|
297
|
+
col_widths = {}
|
|
298
|
+
for key in keys:
|
|
299
|
+
col_widths[key] = len(key)
|
|
300
|
+
|
|
301
|
+
for row in data[:max_rows]:
|
|
302
|
+
if isinstance(row, dict):
|
|
303
|
+
for key in keys:
|
|
304
|
+
val = str(row.get(key, ""))
|
|
305
|
+
col_widths[key] = max(col_widths[key], len(val))
|
|
306
|
+
|
|
307
|
+
# Cap widths
|
|
308
|
+
for key in keys:
|
|
309
|
+
col_widths[key] = min(col_widths[key], 30)
|
|
310
|
+
|
|
311
|
+
# Print header
|
|
312
|
+
header = " | ".join(key[:col_widths[key]].ljust(col_widths[key]) for key in keys)
|
|
313
|
+
print(header)
|
|
314
|
+
print("-" * len(header))
|
|
315
|
+
|
|
316
|
+
# Print rows
|
|
317
|
+
shown = 0
|
|
318
|
+
for row in data:
|
|
319
|
+
if not isinstance(row, dict):
|
|
320
|
+
continue
|
|
321
|
+
if shown >= max_rows:
|
|
322
|
+
remaining = len(data) - shown
|
|
323
|
+
print(f"... ({remaining} more rows)")
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
values = []
|
|
327
|
+
for key in keys:
|
|
328
|
+
val = str(row.get(key, ""))
|
|
329
|
+
if len(val) > col_widths[key]:
|
|
330
|
+
val = val[:col_widths[key] - 3] + "..."
|
|
331
|
+
values.append(val.ljust(col_widths[key]))
|
|
332
|
+
|
|
333
|
+
print(" | ".join(values))
|
|
334
|
+
shown += 1
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _print_parsed_data_rich(data, max_rows=20):
|
|
338
|
+
"""Pretty print parsed data using Rich library."""
|
|
339
|
+
try:
|
|
340
|
+
from rich.console import Console
|
|
341
|
+
from rich.table import Table
|
|
342
|
+
except ImportError:
|
|
343
|
+
print("⚠️ Rich library not available, falling back to text format")
|
|
344
|
+
_print_parsed_data(data, max_rows)
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
if not data:
|
|
348
|
+
print("(empty)")
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
if not isinstance(data, list):
|
|
352
|
+
import json
|
|
353
|
+
print(json.dumps(data, indent=2))
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# Get all unique keys
|
|
357
|
+
all_keys = set()
|
|
358
|
+
for row in data:
|
|
359
|
+
if isinstance(row, dict):
|
|
360
|
+
all_keys.update(row.keys())
|
|
361
|
+
|
|
362
|
+
keys = sorted(all_keys)
|
|
363
|
+
|
|
364
|
+
if not keys:
|
|
365
|
+
import json
|
|
366
|
+
print(json.dumps(data, indent=2))
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
# Create rich table
|
|
370
|
+
console = Console()
|
|
371
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
372
|
+
|
|
373
|
+
# Add columns
|
|
374
|
+
for key in keys:
|
|
375
|
+
table.add_column(key, style="white", no_wrap=False, max_width=30)
|
|
376
|
+
|
|
377
|
+
# Add rows
|
|
378
|
+
shown = 0
|
|
379
|
+
for row in data:
|
|
380
|
+
if not isinstance(row, dict):
|
|
381
|
+
continue
|
|
382
|
+
if shown >= max_rows:
|
|
383
|
+
remaining = len(data) - shown
|
|
384
|
+
console.print(f"[yellow]... ({remaining} more rows)[/yellow]")
|
|
385
|
+
break
|
|
386
|
+
|
|
387
|
+
values = [str(row.get(key, "")) for key in keys]
|
|
388
|
+
table.add_row(*values)
|
|
389
|
+
shown += 1
|
|
390
|
+
|
|
391
|
+
console.print(table)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _print_parsed_data_json(data):
|
|
395
|
+
"""Print parsed data as JSON."""
|
|
396
|
+
import json
|
|
397
|
+
print(json.dumps(data, indent=2))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _display_parsed_result(data, output_format, max_rows=20):
|
|
401
|
+
"""Display parsed data in the specified format."""
|
|
402
|
+
if output_format == "json":
|
|
403
|
+
_print_parsed_data_json(data)
|
|
404
|
+
elif output_format == "rich":
|
|
405
|
+
_print_parsed_data_rich(data, max_rows)
|
|
406
|
+
else: # text or fallback
|
|
407
|
+
_print_parsed_data(data, max_rows)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# Convenience function to add to API
|
|
411
|
+
def add_repl_to_api(api_instance):
|
|
412
|
+
"""Add repl() method to API instance."""
|
|
413
|
+
|
|
414
|
+
def repl(policy: Optional[REPLPolicy] = None):
|
|
415
|
+
"""Start interactive REPL."""
|
|
416
|
+
start_repl(api=api_instance, policy=policy)
|
|
417
|
+
|
|
418
|
+
api_instance.repl = repl
|
|
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
|
|
20
20
|
IPYTHON_STARTUP = '''
|
|
21
21
|
from nterm.scripting import api
|
|
22
22
|
print("\\n\\033[1;36mnterm API loaded.\\033[0m")
|
|
23
|
+
print(" api.repl() _ Start CLI/repl")
|
|
23
24
|
print(" api.devices() - List saved devices")
|
|
24
25
|
print(" api.search(query) - Search devices")
|
|
25
26
|
print(" api.credentials() - List credentials (after api.unlock())")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ntermqt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
|
|
5
5
|
Author: Scott Peterman
|
|
6
6
|
License: GPL-3.0
|
|
@@ -29,6 +29,7 @@ Requires-Dist: click>=8.0.0
|
|
|
29
29
|
Requires-Dist: ipython>=8.0.0
|
|
30
30
|
Requires-Dist: requests>=2.10.0
|
|
31
31
|
Requires-Dist: textfsm>=2.0.0
|
|
32
|
+
Requires-Dist: rich>=14.0.0
|
|
32
33
|
Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
|
|
33
34
|
Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
|
|
34
35
|
Provides-Extra: keyring
|
|
@@ -110,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
|
|
|
110
111
|
|
|
111
112
|

|
|
112
113
|
|
|
114
|
+

|
|
115
|
+
|
|
113
116
|
The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
|
|
114
117
|
|
|
115
118
|
```python
|
|
@@ -25,6 +25,8 @@ nterm/parser/tfsm_fire_tester.py
|
|
|
25
25
|
nterm/scripting/__init__.py
|
|
26
26
|
nterm/scripting/api.py
|
|
27
27
|
nterm/scripting/cli.py
|
|
28
|
+
nterm/scripting/repl.py
|
|
29
|
+
nterm/scripting/repl_interactive.py
|
|
28
30
|
nterm/session/__init__.py
|
|
29
31
|
nterm/session/askpass_ssh.py
|
|
30
32
|
nterm/session/base.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ntermqt"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Modern SSH terminal widget for PyQt6 with credential vault and jump host support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "GPL-3.0"}
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
"ipython>=8.0.0",
|
|
35
35
|
"requests>=2.10.0",
|
|
36
36
|
"textfsm>=2.0.0",
|
|
37
|
+
"rich>=14.0.0",
|
|
37
38
|
# Platform-specific PTY support
|
|
38
39
|
"pexpect>=4.8.0; sys_platform != 'win32'",
|
|
39
40
|
"pywinpty>=2.0.0; sys_platform == 'win32'",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|